[Audiosharing] Migrate feature from overlay to Settings
Bug: 340379827
Test: atest
Change-Id: I3a88ac1d2f575f3be1f26f617479bbfd25cf6a8e
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index fd9f2e5..57fd25f3 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -5194,6 +5194,56 @@
android:theme="@style/Theme.SpaLib.Dialog">
</activity>
+ <activity
+ android:name="com.android.settings.connecteddevice.audiosharing.AudioSharingActivity"
+ android:label="@string/audio_sharing_title"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="com.android.settings.BLUETOOTH_AUDIO_SHARING_SETTINGS" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ <meta-data android:name="com.android.settings.FRAGMENT_CLASS"
+ android:value="com.android.settings.connecteddevice.audiosharing.AudioSharingDashboardFragment"/>
+ </activity>
+
+ <activity
+ android:name="com.android.settings.connecteddevice.audiosharing.audiostreams.qrcode.QrCodeScanModeActivity"
+ android:permission="android.permission.BLUETOOTH_CONNECT"
+ android:screenOrientation="portrait"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="android.settings.BLUETOOTH_LE_AUDIO_QR_CODE_SCANNER"/>
+ <category android:name="android.intent.category.DEFAULT"/>
+ </intent-filter>
+ </activity>
+
+ <activity
+ android:name="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamConfirmDialogActivity"
+ android:exported="true"
+ android:theme="@style/Transparent"
+ android:configChanges="orientation|keyboardHidden|screenSize">
+ <intent-filter android:priority="1">
+ <action android:name="android.settings.AUDIO_STREAM_DIALOG" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ <meta-data android:name="com.android.settings.FRAGMENT_CLASS"
+ android:value="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamConfirmDialog" />
+ </activity>
+
+ <service
+ android:name="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService"
+ android:foregroundServiceType="mediaPlayback"
+ android:enabled="true"
+ android:exported="false" />
+
+ <receiver android:name="com.android.settings.connecteddevice.audiosharing.AudioSharingReceiver"
+ android:exported="false">
+ <intent-filter>
+ <action android:name="com.android.settings.action.BLUETOOTH_LE_AUDIO_SHARING_STATE_CHANGE" />
+ <action android:name="com.android.settings.action.BLUETOOTH_LE_AUDIO_SHARING_STOP" />
+ </intent-filter>
+ </receiver>
+
<!-- This is the longest AndroidManifest.xml ever. -->
</application>
</manifest>
diff --git a/res-product/values/strings.xml b/res-product/values/strings.xml
index c9dc248..a42153e 100644
--- a/res-product/values/strings.xml
+++ b/res-product/values/strings.xml
@@ -745,4 +745,17 @@
<string name="spatial_audio_speaker" product="tablet">Tablet speakers</string>
<!-- Output device type for the phone speaker that is available for spatializer effect. [CHAR LIMIT=NONE]-->
<string name="spatial_audio_speaker" product="device">Device speakers</string>
+
+ <!-- Content for audio sharing share dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_dialog_share_content" product="default">This phone\'s music and videos will play on both pairs of headphones</string>
+ <string name="audio_sharing_dialog_share_content" product="tablet">This tablet\'s music and videos will play on both pairs of headphones</string>
+ <string name="audio_sharing_dialog_share_content" product="device">This device\'s music and videos will play on both pairs of headphones</string>
+ <!-- Content for audio sharing share dialog with more devices [CHAR LIMIT=none]-->
+ <string name="audio_sharing_dialog_share_more_content" product="default">This phone\'s music and videos will play on the headphones you connect</string>
+ <string name="audio_sharing_dialog_share_more_content" product="tablet">This tablet\'s music and videos will play on the headphones you connect</string>
+ <string name="audio_sharing_dialog_share_more_content" product="device">This device\'s music and videos will play on the headphones you connect</string>
+ <!-- Le audio streams no le device dialog subtitle [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_no_le_device_subtitle" product="default">To listen to an audio stream, first connect headphones that support LE Audio to this phone.</string>
+ <string name="audio_streams_dialog_no_le_device_subtitle" product="tablet">To listen to an audio stream, first connect headphones that support LE Audio to this tablet.</string>
+ <string name="audio_streams_dialog_no_le_device_subtitle" product="device">To listen to an audio stream, first connect headphones that support LE Audio to this device.</string>
</resources>
diff --git a/res/drawable/audio_sharing_guidance.png b/res/drawable/audio_sharing_guidance.png
new file mode 100644
index 0000000..c0ab637
--- /dev/null
+++ b/res/drawable/audio_sharing_guidance.png
Binary files differ
diff --git a/res/drawable/audio_sharing_rounded_bg.xml b/res/drawable/audio_sharing_rounded_bg.xml
new file mode 100644
index 0000000..db1e1bb
--- /dev/null
+++ b/res/drawable/audio_sharing_rounded_bg.xml
@@ -0,0 +1,22 @@
+<?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.
+ -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+ <solid android:color="?android:colorButtonNormal" />
+ <corners android:radius="12dp" />
+</shape>
\ No newline at end of file
diff --git a/res/drawable/audio_sharing_rounded_bg_ripple.xml b/res/drawable/audio_sharing_rounded_bg_ripple.xml
new file mode 100644
index 0000000..18696c6
--- /dev/null
+++ b/res/drawable/audio_sharing_rounded_bg_ripple.xml
@@ -0,0 +1,21 @@
+<?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.
+ -->
+
+<ripple xmlns:android="http://schemas.android.com/apk/res/android"
+ android:color="?android:attr/colorControlHighlight">
+ <item android:drawable="@drawable/audio_sharing_rounded_bg"/>
+</ripple>
\ No newline at end of file
diff --git a/res/drawable/ic_audio_calls_and_alarms.xml b/res/drawable/ic_audio_calls_and_alarms.xml
new file mode 100644
index 0000000..5da27c6
--- /dev/null
+++ b/res/drawable/ic_audio_calls_and_alarms.xml
@@ -0,0 +1,32 @@
+<!--
+ Copyright (C) 2018 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.
+ -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24.0"
+ android:viewportHeight="24.0"
+ android:tint="?android:attr/colorControlNormal">
+ <path
+ android:pathData="M3,15V9H7L12,4V20L7,15H3ZM10,15.17V8.83L7.83,11H5V13H7.83L10,15.17Z"
+ android:fillType="evenOdd"
+ android:fillColor="?android:attr/colorPrimary"/>
+ <path
+ android:pathData="M16.5,12C16.5,10.23 15.48,8.71 14,7.97V16.02C15.48,15.29 16.5,13.77 16.5,12Z"
+ android:fillColor="?android:attr/colorPrimary"/>
+ <path
+ android:pathData="M14,3.23V5.29C16.89,6.15 19,8.83 19,12C19,15.17 16.89,17.85 14,18.71V20.77C18.01,19.86 21,16.28 21,12C21,7.72 18.01,4.14 14,3.23Z"
+ android:fillColor="?android:attr/colorPrimary"/>
+</vector>
diff --git a/res/drawable/ic_audio_play_sample.xml b/res/drawable/ic_audio_play_sample.xml
new file mode 100644
index 0000000..3666c22
--- /dev/null
+++ b/res/drawable/ic_audio_play_sample.xml
@@ -0,0 +1,32 @@
+<!--
+ ~ 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:tint="?android:attr/colorControlNormal">
+ <path
+ android:pathData="M14,8C9.6,8 6,11.6 6,16H8C8,12.7 10.7,10 14,10V8Z"
+ android:fillColor="#4E4639"/>
+ <path
+ android:pathData="M14,6V4C7.4,4 2,9.4 2,16H4C4,10.5 8.5,6 14,6Z"
+ android:fillColor="#4E4639"/>
+ <path
+ android:pathData="M16,4V12.6C15.4,12.3 14.7,12 14,12C11.8,12 10,13.8 10,16C10,18.2 11.8,20 14,20C16.2,20 18,18.2 18,16V7H22V4H16ZM14,18C12.9,18 12,17.1 12,16C12,14.9 12.9,14 14,14C15.1,14 16,14.9 16,16C16,17.1 15.1,18 14,18Z"
+ android:fillColor="#4E4639"/>
+</vector>
diff --git a/res/layout/audio_sharing_device_item.xml b/res/layout/audio_sharing_device_item.xml
new file mode 100644
index 0000000..04ecdd7
--- /dev/null
+++ b/res/layout/audio_sharing_device_item.xml
@@ -0,0 +1,33 @@
+<?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.
+ -->
+
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <Button
+ android:id="@+id/device_button"
+ style="@style/SettingsLibActionButton"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:background="@drawable/audio_sharing_rounded_bg_ripple"
+ android:textAlignment="center" />
+
+</FrameLayout>
\ No newline at end of file
diff --git a/res/layout/audio_sharing_password_dialog.xml b/res/layout/audio_sharing_password_dialog.xml
new file mode 100644
index 0000000..f1a78bc
--- /dev/null
+++ b/res/layout/audio_sharing_password_dialog.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2022 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.
+ -->
+<ScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginTop="48dp"
+ android:layout_marginBottom="48dp"
+ android:overScrollMode="ifContentScrolls">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <EditText
+ android:id="@android:id/edit"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:layout_marginEnd="20dp"
+ android:minHeight="48dp" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+
+ <TextView
+ android:id="@+id/audio_sharing_stream_password_checkbox_text"
+ style="?android:attr/textAppearanceSmall"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="24dp"
+ android:layout_marginTop="24dp"
+ android:layout_weight="1"
+ android:text="@string/audio_streams_no_password_summary"
+ android:textColor="?android:attr/textColorSecondary" />
+
+ <CheckBox
+ android:id="@+id/audio_sharing_stream_password_checkbox"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="24dp"
+ android:layout_marginEnd="20dp" />
+ </LinearLayout>
+
+ <TextView
+ android:id="@android:id/message"
+ style="?android:attr/textAppearanceSmall"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="24dp"
+ android:layout_marginTop="24dp"
+ android:layout_marginEnd="24dp"
+ android:layout_marginBottom="24dp"
+ android:text="@string/audio_streams_main_page_password_dialog_cannot_edit"
+ android:textColor="?android:attr/textColorSecondary" />
+
+ </LinearLayout>
+</ScrollView>
\ No newline at end of file
diff --git a/res/layout/dialog_custom_body_audio_sharing.xml b/res/layout/dialog_custom_body_audio_sharing.xml
new file mode 100644
index 0000000..388a4941
--- /dev/null
+++ b/res/layout/dialog_custom_body_audio_sharing.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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.
+ -->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingHorizontal="?android:dialogPreferredPadding"
+ android:paddingBottom="?android:dialogPreferredPadding">
+
+ <TextView
+ android:id="@+id/description_text"
+ style="@style/DeviceAudioSharingText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:paddingBottom="24dp"
+ android:visibility="gone" />
+
+ <ImageView
+ android:id="@+id/description_image"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:contentDescription="@null"
+ android:visibility="gone" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/device_btn_list"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:nestedScrollingEnabled="false"
+ android:overScrollMode="never"
+ android:visibility="gone" />
+
+ <Button
+ android:id="@+id/positive_btn"
+ style="@style/SettingsLibActionButton"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:layout_marginTop="4dp"
+ android:background="@drawable/audio_sharing_rounded_bg_ripple"
+ android:visibility="gone" />
+
+ <Button
+ android:id="@+id/negative_btn"
+ style="@style/SettingsLibActionButton"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:layout_marginTop="4dp"
+ android:background="@drawable/audio_sharing_rounded_bg_ripple"
+ android:visibility="gone" />
+</LinearLayout>
\ No newline at end of file
diff --git a/res/layout/dialog_custom_title_audio_sharing.xml b/res/layout/dialog_custom_title_audio_sharing.xml
new file mode 100644
index 0000000..86e0010
--- /dev/null
+++ b/res/layout/dialog_custom_title_audio_sharing.xml
@@ -0,0 +1,42 @@
+<?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:orientation="vertical"
+ android:padding="?android:dialogPreferredPadding">
+
+ <ImageView
+ android:id="@+id/title_icon"
+ android:layout_width="28dp"
+ android:layout_height="28dp"
+ android:layout_gravity="center"
+ android:contentDescription="@null"
+ android:tint="?android:attr/colorControlNormal" />
+
+ <TextView
+ android:id="@+id/title_text"
+ style="@android:style/TextAppearance.DeviceDefault.Headline"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:maxLines="2"
+ android:paddingTop="14dp"
+ android:textAlignment="center"
+ android:textSize="24sp" />
+</LinearLayout>
\ No newline at end of file
diff --git a/res/layout/preference_widget_lock.xml b/res/layout/preference_widget_lock.xml
new file mode 100644
index 0000000..6ef088f
--- /dev/null
+++ b/res/layout/preference_widget_lock.xml
@@ -0,0 +1,29 @@
+<?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.
+-->
+
+<ImageView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/lock_icon"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_gravity="center"
+ android:paddingStart="24dp"
+ android:paddingEnd="24dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:scaleType="center"
+ android:src="@drawable/ic_lock_closed"
+ android:importantForAccessibility="no" />
+
diff --git a/res/layout/qrcode_scanner_fragment.xml b/res/layout/qrcode_scanner_fragment.xml
index d402dc3..d24e7f7 100644
--- a/res/layout/qrcode_scanner_fragment.xml
+++ b/res/layout/qrcode_scanner_fragment.xml
@@ -35,8 +35,8 @@
android:gravity="center"
android:orientation="vertical">
<TextView
+ android:id="@android:id/summary"
style="@style/QrCodeScanner"
- android:text="Scan an audio stream QR code to listen with the active LE device"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="wrap_content"
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index e957ea5..dcfc410 100755
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -488,4 +488,7 @@
<dimen name="contrast_button_text_size">14sp</dimen>
<dimen name="contrast_button_text_spacing">4dp</dimen>
<dimen name="contrast_button_horizontal_spacing">16dp</dimen>
+
+ <dimen name="audio_streams_qrcode_size">264dp</dimen>
+ <dimen name="audio_streams_qrcode_preview_radius">30dp</dimen>
</resources>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index f88b036..c8d2226 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -303,9 +303,6 @@
<!-- Name shown in the title of individual stylus preference in the connected devices page [CHAR LIMIT=60] -->
<string name="stylus_connected_devices_title">Stylus</string>
- <!--Text that appears when scanning for nearby audio streams is finished and no streams were found [CHAR LIMIT=40]-->
- <string name="audio_streams_empty">No nearby audio streams were found.</string>
-
<!-- Date & time settings screen title -->
<string name="date_and_time">Date & time</string>
@@ -7315,8 +7312,6 @@
<string name="help_url_insecure_vpn" translatable="false"></string>
<!-- url for learning more about IT admin policy disabling -->
<string name="help_url_action_disabled_by_it_admin" translatable="false"></string>
- <!-- url for learning more about bluetooth audio sharing -->
- <string name="help_url_audio_sharing" translatable="false"></string>
<!-- User account title [CHAR LIMIT=30] -->
<string name="user_account_title">Account for content</string>
@@ -13270,4 +13265,198 @@
<!-- Title for System dashboard fragment -->
<string name="device_diagnostics_title">Device diagnostics</string>
+
+ <!-- Title for audio sharing page [CHAR LIMIT=none]-->
+ <string name="audio_sharing_title">Audio sharing</string>
+ <!-- Title for audio sharing primary switch [CHAR LIMIT=none]-->
+ <string name="audio_sharing_switch_title">Share audio</string>
+ <!-- Title for calls and alarms device on audio sharing page [CHAR LIMIT=none]-->
+ <string name="calls_and_alarms_device_title">Calls and alarms</string>
+ <!-- Description for audio sharing page [CHAR LIMIT=none]-->
+ <string name="audio_sharing_description">Let people listen to your media along with you. Listeners need their own LE Audio headphones.</string>
+ <!-- Title for audio sharing device group [CHAR LIMIT=none]-->
+ <string name="audio_sharing_device_group_title">Active media devices</string>
+ <!-- Title for call audio on audio sharing page [CHAR LIMIT=none]-->
+ <string name="audio_sharing_call_audio_title">Call audio</string>
+ <!-- Description for call audio on audio sharing page [CHAR LIMIT=none]-->
+ <string name="audio_sharing_call_audio_description">Play only on <xliff:g example="My buds" id="device_name">%1$s</xliff:g></string>
+ <!-- Title for play test sound on audio sharing page [CHAR LIMIT=none]-->
+ <string name="audio_sharing_test_sound_title">Play a test sound</string>
+ <!-- Description for play test sound on audio sharing page [CHAR LIMIT=none]-->
+ <string name="audio_sharing_test_sound_description">Everyone listening should hear it</string>
+ <!-- Title for stream settings group on audio sharing page [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stream_settings_title">Audio stream settings</string>
+ <!-- Title for stream name on audio sharing page, under stream settings group [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stream_name_title">Name</string>
+ <!-- Title for stream password on audio sharing page, under stream settings group [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stream_password_title">Password</string>
+ <!-- Title for stream compatibility on audio sharing page, under stream settings group [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stream_compatibility_title">Improve compatibility</string>
+ <!-- Description for stream compatibility on audio sharing page [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stream_compatibility_description">Helps some devices, like hearing aids, connect by reducing audio quality</string>
+ <!-- Description for stream compatibility on audio sharing page when audio sharing is on [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stream_compatibility_disabled_description">Turns off the audio sharing to config the compatibility</string>
+ <!-- Title for nearby audio group on audio sharing page [CHAR LIMIT=none]-->
+ <string name="audio_sharing_nearby_audio_title">Listen to nearby audio</string>
+ <!-- Description for audio sharing page footer [CHAR LIMIT=none]-->
+ <string name="audio_sharing_footer_description">Audio sharing supports Auracast™</string>
+ <!-- Title for stream name dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stream_name_dialog_title">Audio stream name</string>
+ <!-- Title for stream password dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stream_password_dialog_title">Audio stream password</string>
+ <!-- Title for media device group during audio sharing [CHAR LIMIT=none]-->
+ <string name="audio_sharing_media_device_group_title">Other media devices</string>
+ <!-- Summary for audio sharing on [CHAR LIMIT=none]-->
+ <string name="audio_sharing_summary_on">On</string>
+ <!-- Summary for audio sharing off [CHAR LIMIT=none]-->
+ <string name="audio_sharing_summary_off">Off</string>
+ <!-- Title for audio sharing share dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_share_dialog_title">Share your audio</string>
+ <!-- Subtitle for audio sharing share dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_share_dialog_subtitle"><xliff:g example="My buds1" id="device_name1">%1$s</xliff:g> and <xliff:g example="My buds2" id="device_name2">%2$s</xliff:g></string>
+ <!-- Text for audio sharing share button [CHAR LIMIT=none]-->
+ <string name="audio_sharing_share_button_label">Share audio</string>
+ <!-- Text for audio sharing no thanks button [CHAR LIMIT=none]-->
+ <string name="audio_sharing_no_thanks_button_label">No thanks</string>
+ <!-- Title for audio sharing share dialog with one device [CHAR LIMIT=none]-->
+ <string name="audio_sharing_share_with_dialog_title">Share audio with <xliff:g example="My buds" id="device_name">%1$s</xliff:g>?</string>
+ <!-- Title for audio sharing share dialog with more devices [CHAR LIMIT=none]-->
+ <string name="audio_sharing_share_with_more_dialog_title">Share audio with another device</string>
+ <!-- Text for audio sharing share with button [CHAR LIMIT=none]-->
+ <string name="audio_sharing_share_with_button_label">Share with <xliff:g example="My buds" id="device_name">%1$s</xliff:g></string>
+ <!-- Text for audio sharing close button [CHAR LIMIT=none]-->
+ <string name="audio_sharing_close_button_label">Close</string>
+ <!-- Content for audio sharing share dialog with no device, ask users to connect device [CHAR LIMIT=none]-->
+ <string name="audio_sharing_dialog_connect_device_content">Connect another pair of compatible headphones, or share your stream\'s name and password with the other person</string>
+ <!-- Content for audio sharing share dialog with no device, ask users to pair device [CHAR LIMIT=none]-->
+ <string name="audio_sharing_dialog_pair_device_content">Pair another set of compatible headphones, or share your audio stream QR code with the other person</string>
+ <!-- Text for sharing audio sharing state [CHAR LIMIT=none]-->
+ <string name="audio_sharing_sharing_label">Sharing audio</string>
+ <!-- Text for audio sharing pair button [CHAR LIMIT=none]-->
+ <string name="audio_sharing_pair_button_label">Pair new device</string>
+ <!-- Text for audio sharing qrcode button [CHAR LIMIT=none]-->
+ <string name="audio_sharing_qrcode_button_label">Show QR code</string>
+ <!-- Title for audio sharing notification [CHAR LIMIT=none]-->
+ <string name="audio_sharing_notification_title">You\'re sharing audio</string>
+ <!-- Content for audio sharing notification [CHAR LIMIT=none]-->
+ <string name="audio_sharing_notification_content">People listening can hear your media. They won\'t hear calls.</string>
+ <!-- Text for audio sharing stop button [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stop_button_label">Stop sharing</string>
+ <!-- Text for audio sharing settings button [CHAR LIMIT=none]-->
+ <string name="audio_sharing_settings_button_label">Settings</string>
+ <!-- Title for audio sharing disconnect dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_disconnect_dialog_title">Choose a device to disconnect</string>
+ <!-- Content for audio sharing disconnect dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_dialog_disconnect_content">Only 2 devices can share audio at a time</string>
+ <!-- Text for audio sharing disconnect device button [CHAR LIMIT=none]-->
+ <string name="audio_sharing_disconnect_device_button_label">Disconnect <xliff:g example="My buds" id="device_name">%1$s</xliff:g></string>
+ <!-- Title for audio sharing stop dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stop_dialog_title">Connect <xliff:g example="My buds" id="device_name">%1$s</xliff:g> ?</string>
+ <!-- Content for audio sharing stop dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stop_dialog_content">You\'ll stop sharing audio with <xliff:g example="My buds" id="device_name">%1$s</xliff:g></string>
+ <!-- Content for audio sharing stop dialog with two devices [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stop_dialog_with_two_content">You\'ll stop sharing audio with <xliff:g example="My buds1" id="device_name1">%1$s</xliff:g> and <xliff:g example="My buds2" id="device_name2">%2$s</xliff:g></string>
+ <!-- Content for audio sharing stop dialog with more devices [CHAR LIMIT=none]-->
+ <string name="audio_sharing_stop_dialog_with_more_content">You\'ll stop sharing audio with the connected headphones</string>
+ <!-- Text for audio sharing connect button [CHAR LIMIT=none]-->
+ <string name="audio_sharing_connect_button_label">Connect</string>
+ <!-- Text for sharing audio stop state [CHAR LIMIT=none]-->
+ <string name="audio_sharing_sharing_stopped_label">Audio sharing stopped</string>
+ <!-- Title for audio sharing confirm dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_confirm_dialog_title">Connect a compatible device</string>
+ <!-- Content for audio sharing confirm dialog [CHAR LIMIT=none]-->
+ <string name="audio_sharing_comfirm_dialog_content">To start sharing audio, first connect LE Audio headphones to your phone</string>
+
+ <!-- Title for audio streams preference category [CHAR LIMIT=none]-->
+ <string name="audio_streams_category_title">Connect to a LE audio stream</string>
+ <!-- Title for audio streams preference [CHAR LIMIT=none]-->
+ <string name="audio_streams_pref_title">Nearby audio streams</string>
+ <!-- Title for audio streams page [CHAR LIMIT=none]-->
+ <string name="audio_streams_title">Audio streams</string>
+ <!-- Summary for QR code scanning in audio streams page [CHAR LIMIT=none]-->
+ <string name="audio_streams_qr_code_summary">Connect to an audio stream using QR code</string>
+ <!--Text that appears when scanning for nearby audio streams is finished and no streams were found [CHAR LIMIT=40]-->
+ <string name="audio_streams_empty">No nearby audio streams were found.</string>
+ <!-- Disconnect from an audio stream [CHAR LIMIT=none]-->
+ <string name="audio_streams_disconnect">Disconnect</string>
+ <!-- Connect an audio stream [CHAR LIMIT=none]-->
+ <string name="audio_streams_connect">Connect</string>
+ <!-- Hint for QR code process failure [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_qr_code_is_not_valid_format">QR code isn\u0027t a valid format</string>
+ <!-- Le audio QR code scanner sub-title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_qr_code_scanner">To start listening, center the QR code below</string>
+ <!-- The preference summary when add source response is bad code [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_add_source_bad_code_state_summary">Check password and try again</string>
+ <!-- The preference summary when add source response results in general failure [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_add_source_failed_state_summary">Can\u0027t connect. Try again.</string>
+ <!-- The preference summary when waiting for add source response [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_add_source_wait_for_response_summary">Connecting\u2026</string>
+ <!-- The preference summary when waiting for sync [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_wait_for_sync_state_summary">Scanning\u2026</string>
+ <!-- Le audio streams audio lost dialog title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_stream_is_not_available">Audio stream isn\u0027t available</string>
+ <!-- Le audio streams audio lost dialog subtitle [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_is_not_playing">This audio stream isn\u0027t playing anything right now</string>
+ <!-- Le audio streams dialog close [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_close">Close</string>
+ <!-- Le audio streams dialog listen [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_listen">Listen</string>
+ <!-- Le audio streams dialog retry button [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_retry">Try again</string>
+ <!-- Le audio streams confirmation dialog title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_listen_to_audio_stream">Listen to audio stream</string>
+ <!-- Le audio streams confirmation dialog subtitle [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_control_volume">The audio stream will play on <xliff:g example="LE headset" id="device_name">%1$s</xliff:g>. Use this device to control the volume.</string>
+ <!-- Le audio streams failure dialog title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_cannot_listen">Can\u0027t listen to audio stream</string>
+ <!-- Le audio streams confirm dialog default device [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_default_device">connected compatible headphones</string>
+ <!-- Le audio streams activity title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_activity_title">Broadcasts</string>
+ <!-- Le audio streams no password summary [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_no_password_summary">No password</string>
+ <!-- Le audio streams failure dialog subtitle [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_learn_more">Learn more</string>
+ <!-- Le audio streams failure dialog subtitle [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_cannot_play">Can\u0027t play this audio stream on <xliff:g example="LE headset" id="device_name">%1$s</xliff:g>.</string>
+ <!-- The preference summary when add source succeed [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_listening_now">Listening now</string>
+ <!-- Le audio streams service notification leave broadcast text [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_media_service_notification_leave_broadcast_text">Stop listening</string>
+ <!-- Le audio streams no le device dialog title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_no_le_device_title">Connect compatible headphones</string>
+ <!-- Le audio streams no le device dialog button [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_dialog_no_le_device_button">Connect a device</string>
+ <!-- Le audio streams detail page title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_detail_page_title">Audio stream details</string>
+ <!-- Le audio streams qr code page title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_qr_code_page_title">Audio stream QR code</string>
+ <!-- Le audio streams qr code page password text [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_qr_code_page_password">Password: <xliff:g example="123" id="password">%1$s</xliff:g></string>
+ <!-- Le audio streams qr code page description [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_qr_code_page_description">To listen to <xliff:g example="Local Music" id="stream_name">%1$s</xliff:g>, other people can connect compatible headphones to their Android device. They can then scan this QR code.</string>
+ <!-- Le audio streams main page title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_main_page_title">Find an audio stream</string>
+ <!-- Le audio streams main page subtitle [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_main_page_subtitle">Listen to a device that\u0027s sharing audio or to a nearby Auracast broadcast</string>
+ <!-- Le audio streams main page device preference title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_main_page_device_title">Your audio device</string>
+ <!-- Le audio streams main page device preference no device summary [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_main_page_no_device_summary">Connect compatible headphones</string>
+ <!-- Le audio streams main page scanning section title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_main_page_scan_section_title">Audio streams nearby</string>
+ <!-- Le audio streams main page scan qr code preference title [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_main_page_scan_qr_code_title">Scan QR code</string>
+ <!-- Le audio streams main page scan qr code preference summary [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_main_page_scan_qr_code_summary">Start listening by scanning a stream\u0027s QR code</string>
+ <!-- Le audio streams main page password dialog join button [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_main_page_password_dialog_join_button">Listen to stream</string>
+ <!-- Le audio streams main page qr code scanner summary [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_main_page_qr_code_scanner_summary">Scan an audio stream QR code to listen with <xliff:g example="LE headset" id="device_name">%1$s</xliff:g></string>
+ <!-- Le audio streams password dialog [CHAR LIMIT=NONE] -->
+ <string name="audio_streams_main_page_password_dialog_cannot_edit">Can\u0027t edit password while sharing. To change the password, first turn off audio sharing.</string>
+
+
+ <!-- url for learning more about bluetooth audio sharing -->
+ <string name="help_url_audio_sharing" translatable="false"></string>
</resources>
diff --git a/res/xml/bluetooth_audio_streams_dialog.xml b/res/xml/bluetooth_audio_streams_dialog.xml
new file mode 100644
index 0000000..8b20a14
--- /dev/null
+++ b/res/xml/bluetooth_audio_streams_dialog.xml
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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.
+ -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <LinearLayout
+ android:id="@+id/dialog_bg"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:paddingStart="25dp"
+ android:paddingEnd="25dp"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="25dp"
+ android:orientation="vertical">
+
+ <ImageView
+ android:id="@+id/dialog_icon"
+ android:layout_width="30dp"
+ android:layout_height="30dp"
+ android:layout_marginTop="24dp"
+ android:layout_gravity="center"
+ android:src="@drawable/ic_bt_le_audio_sharing"/>
+
+ <TextView
+ android:id="@+id/dialog_title"
+ android:textAppearance="@android:style/TextAppearance.DeviceDefault.Headline"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="15dp"
+ android:gravity="center"
+ android:layout_gravity="center"/>
+
+ <TextView
+ android:id="@+id/dialog_subtitle"
+ android:textAppearance="@android:style/TextAppearance.DeviceDefault.Small"
+ android:textStyle="bold"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="15dp"
+ android:gravity="center"
+ android:layout_gravity="center"
+ android:visibility="gone"/>
+
+ <TextView
+ android:id="@+id/dialog_subtitle_2"
+ android:textAppearance="@android:style/TextAppearance.DeviceDefault.Small"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="15dp"
+ android:gravity="center"
+ android:layout_gravity="center"
+ android:visibility="gone"/>
+ </LinearLayout>
+
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="@dimen/broadcast_dialog_margin">
+ <Button
+ android:id="@+id/left_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ style="@style/BroadcastActionButton"/>
+ <Button
+ android:id="@+id/right_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:visibility="gone"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ style="@style/BroadcastActionButton"/>
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ </LinearLayout>
+</FrameLayout>
\ No newline at end of file
diff --git a/res/xml/bluetooth_audio_streams_qr_code.xml b/res/xml/bluetooth_audio_streams_qr_code.xml
new file mode 100644
index 0000000..a098845
--- /dev/null
+++ b/res/xml/bluetooth_audio_streams_qr_code.xml
@@ -0,0 +1,63 @@
+<?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="match_parent">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipToPadding="false"
+ android:paddingLeft="25dp"
+ android:paddingRight="25dp"
+ android:gravity="center_horizontal"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@android:id/summary"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:gravity="start"
+ android:textSize="15sp"
+ android:textColor="?android:attr/textColorPrimary"/>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center"
+ android:orientation="vertical"
+ android:paddingTop="70dp">
+
+ <ImageView
+ android:id="@+id/qrcode_view"
+ android:layout_width="@dimen/qrcode_size"
+ android:layout_height="@dimen/qrcode_size"
+ android:src="@android:color/transparent"/>
+
+ <TextView
+ android:id="@+id/password"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="15sp"
+ android:textColor="?android:attr/textColorPrimary"/>
+ </LinearLayout>
+
+ </LinearLayout>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/res/xml/bluetooth_le_audio_sharing.xml b/res/xml/bluetooth_le_audio_sharing.xml
new file mode 100644
index 0000000..8ba6c07
--- /dev/null
+++ b/res/xml/bluetooth_le_audio_sharing.xml
@@ -0,0 +1,81 @@
+<?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.
+ -->
+<PreferenceScreen
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:settings="http://schemas.android.com/apk/res-auto"
+ android:title="@string/audio_sharing_title">
+
+ <com.android.settingslib.widget.TopIntroPreference
+ android:key="audio_sharing_top_intro"
+ android:title="@string/audio_sharing_description"
+ settings:searchable="false" />
+
+ <PreferenceCategory
+ android:key="audio_sharing_device_volume_group"
+ android:title="@string/audio_sharing_device_group_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.AudioSharingDeviceVolumeGroupController" />
+
+ <Preference
+ android:icon="@drawable/ic_audio_calls_and_alarms"
+ android:key="calls_and_alarms"
+ android:summary=""
+ android:title="@string/audio_sharing_call_audio_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.CallsAndAlarmsPreferenceController" />
+
+ <Preference
+ android:icon="@drawable/ic_audio_play_sample"
+ android:key="audio_sharing_play_sound"
+ android:summary="@string/audio_sharing_test_sound_description"
+ android:title="@string/audio_sharing_test_sound_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.AudioSharingPlaySoundPreferenceController" />
+
+ <PreferenceCategory
+ android:key="audio_sharing_stream_settings_category"
+ android:title="@string/audio_sharing_stream_settings_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.StreamSettingsCategoryController">
+
+ <com.android.settings.connecteddevice.audiosharing.AudioSharingNamePreference
+ android:key="audio_sharing_stream_name"
+ android:title="@string/audio_sharing_stream_name_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.AudioSharingNamePreferenceController" />
+
+ <com.android.settings.connecteddevice.audiosharing.AudioSharingPasswordPreference
+ android:dialogLayout="@layout/audio_sharing_password_dialog"
+ android:key="audio_sharing_stream_password"
+ android:summary="********"
+ android:title="@string/audio_sharing_stream_password_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.AudioSharingPasswordPreferenceController" />
+
+ <SwitchPreferenceCompat
+ android:key="audio_sharing_stream_compatibility"
+ android:title="@string/audio_sharing_stream_compatibility_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.AudioSharingCompatibilityPreferenceController" />
+ </PreferenceCategory>
+
+ <PreferenceCategory
+ android:key="audio_streams_settings_category"
+ android:title="@string/audio_streams_category_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsCategoryController">
+
+ <Preference
+ android:fragment="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsDashboardFragment"
+ android:icon="@drawable/ic_chevron_right_24dp"
+ android:key="audio_streams_settings"
+ android:title="@string/audio_streams_pref_title" />
+
+ </PreferenceCategory>
+</PreferenceScreen>
\ No newline at end of file
diff --git a/res/xml/bluetooth_le_audio_stream_details_fragment.xml b/res/xml/bluetooth_le_audio_stream_details_fragment.xml
new file mode 100644
index 0000000..883681a
--- /dev/null
+++ b/res/xml/bluetooth_le_audio_stream_details_fragment.xml
@@ -0,0 +1,36 @@
+<?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.
+ -->
+
+<PreferenceScreen
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:settings="http://schemas.android.com/apk/res-auto"
+ android:title="@string/audio_streams_detail_page_title">
+
+ <com.android.settingslib.widget.LayoutPreference
+ android:key="audio_stream_header"
+ android:layout="@layout/settings_entity_header"
+ android:selectable="false"
+ settings:allowDividerBelow="true"
+ settings:searchable="false"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamHeaderController" />
+
+ <com.android.settingslib.widget.ActionButtonsPreference
+ android:key="audio_stream_button"
+ settings:allowDividerBelow="true"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamButtonController" />
+
+</PreferenceScreen>
diff --git a/res/xml/bluetooth_le_audio_streams.xml b/res/xml/bluetooth_le_audio_streams.xml
new file mode 100644
index 0000000..db4bd85
--- /dev/null
+++ b/res/xml/bluetooth_le_audio_streams.xml
@@ -0,0 +1,46 @@
+<?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.
+ -->
+
+<PreferenceScreen
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:settings="http://schemas.android.com/apk/res-auto"
+ android:title="@string/audio_streams_main_page_title">
+
+ <com.android.settingslib.widget.TopIntroPreference
+ android:key="audio_streams_top_intro"
+ android:title="@string/audio_streams_main_page_subtitle"
+ settings:searchable="false" />
+
+ <Preference
+ android:key="audio_streams_active_device"
+ android:title="@string/audio_streams_main_page_device_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsActiveDeviceController" />
+
+ <com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryPreference
+ android:key="audio_streams_nearby_category"
+ android:title="@string/audio_streams_main_page_scan_section_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryController">
+ <Preference
+ android:icon="@drawable/ic_add_24dp"
+ android:key="audio_streams_scan_qr_code"
+ android:order="0"
+ android:summary="@string/audio_streams_main_page_scan_qr_code_summary"
+ android:title="@string/audio_streams_main_page_scan_qr_code_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsScanQrCodeController" />
+ </com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryPreference>
+
+</PreferenceScreen>
\ No newline at end of file
diff --git a/res/xml/connected_devices.xml b/res/xml/connected_devices.xml
index 40ab145..95aa877 100644
--- a/res/xml/connected_devices.xml
+++ b/res/xml/connected_devices.xml
@@ -27,8 +27,22 @@
settings:controller="com.android.settings.slices.SlicePreferenceController" />
<PreferenceCategory
+ android:key="audio_sharing_device_list"
+ android:title="@string/audio_sharing_device_group_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.AudioSharingDevicePreferenceController">
+ <Preference
+ android:fragment="com.android.settings.connecteddevice.audiosharing.AudioSharingDashboardFragment"
+ android:icon="@drawable/ic_bt_le_audio_sharing"
+ android:key="connected_device_audio_sharing_settings"
+ android:order="100"
+ android:title="@string/audio_sharing_title"
+ settings:searchable="false" />
+ </PreferenceCategory>
+
+ <PreferenceCategory
android:key="available_device_list"
- android:title="@string/connected_device_media_device_title"/>
+ android:title="@string/connected_device_media_device_title"
+ settings:controller="com.android.settings.connecteddevice.AvailableMediaDeviceGroupController" />
<PreferenceCategory
android:key="connected_device_list"
diff --git a/res/xml/connected_devices_advanced.xml b/res/xml/connected_devices_advanced.xml
index 68b4c04..779555b 100644
--- a/res/xml/connected_devices_advanced.xml
+++ b/res/xml/connected_devices_advanced.xml
@@ -26,6 +26,15 @@
android:order="-10"
android:title="@string/bluetooth_settings_title" />
+ <Preference
+ android:fragment="com.android.settings.connecteddevice.audiosharing.AudioSharingDashboardFragment"
+ android:icon="@drawable/ic_bt_le_audio_sharing"
+ android:key="audio_sharing_settings"
+ android:order="-9"
+ android:title="@string/audio_sharing_title"
+ settings:controller="com.android.settings.connecteddevice.audiosharing.AudioSharingPreferenceController"
+ settings:searchable="true" />
+
<com.android.settingslib.RestrictedPreference
android:fragment="com.android.settings.connecteddevice.NfcAndPaymentFragment"
android:icon="@drawable/ic_nfc"
diff --git a/src/com/android/settings/bluetooth/AvailableMediaBluetoothDeviceUpdater.java b/src/com/android/settings/bluetooth/AvailableMediaBluetoothDeviceUpdater.java
index 0ee3986..b361bd2 100644
--- a/src/com/android/settings/bluetooth/AvailableMediaBluetoothDeviceUpdater.java
+++ b/src/com/android/settings/bluetooth/AvailableMediaBluetoothDeviceUpdater.java
@@ -23,7 +23,8 @@
import androidx.preference.Preference;
import com.android.settings.connecteddevice.DevicePreferenceCallback;
-import com.android.settings.overlay.FeatureFactory;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
+import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
@@ -76,11 +77,17 @@
// It would show in Available Devices group if the audio sharing flag is disabled or
// the device is not in the audio sharing session.
if (cachedDevice.isConnectedLeAudioDevice()) {
- boolean isAudioSharingFilterMatched =
- FeatureFactory.getFeatureFactory()
- .getAudioSharingFeatureProvider()
- .isAudioSharingFilterMatched(cachedDevice, mLocalManager);
- if (!isAudioSharingFilterMatched) {
+ if (AudioSharingUtils.isFeatureEnabled()
+ && BluetoothUtils.hasConnectedBroadcastSource(
+ cachedDevice, mLocalBtManager)) {
+ Log.d(
+ TAG,
+ "Filter out device : "
+ + cachedDevice.getName()
+ + ", it is in audio sharing.");
+ return false;
+
+ } else {
Log.d(
TAG,
"isFilterMatched() device : "
@@ -88,13 +95,6 @@
+ ", the LE Audio profile is connected and not in sharing "
+ "if broadcast enabled.");
return true;
- } else {
- Log.d(
- TAG,
- "Filter out device : "
- + cachedDevice.getName()
- + ", it is in audio sharing.");
- return false;
}
}
diff --git a/src/com/android/settings/connecteddevice/AvailableMediaDeviceGroupController.java b/src/com/android/settings/connecteddevice/AvailableMediaDeviceGroupController.java
index 56ef4b0..89759b7 100644
--- a/src/com/android/settings/connecteddevice/AvailableMediaDeviceGroupController.java
+++ b/src/com/android/settings/connecteddevice/AvailableMediaDeviceGroupController.java
@@ -17,6 +17,10 @@
import static com.android.settingslib.Utils.isAudioModeOngoingCall;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.content.pm.PackageManager;
@@ -38,13 +42,18 @@
import com.android.settings.bluetooth.BluetoothDevicePreference;
import com.android.settings.bluetooth.BluetoothDeviceUpdater;
import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingDialogHandler;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
-import com.android.settingslib.core.lifecycle.Lifecycle;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
/**
* Controller to maintain the {@link androidx.preference.PreferenceGroup} for all available media
@@ -57,23 +66,78 @@
private static final String TAG = "AvailableMediaDeviceGroupController";
private static final String KEY = "available_device_list";
+ private final Executor mExecutor;
+ @VisibleForTesting @Nullable LocalBluetoothManager mLocalBluetoothManager;
@VisibleForTesting @Nullable PreferenceGroup mPreferenceGroup;
- @VisibleForTesting LocalBluetoothManager mLocalBluetoothManager;
@Nullable private BluetoothDeviceUpdater mBluetoothDeviceUpdater;
@Nullable private FragmentManager mFragmentManager;
+ @Nullable private AudioSharingDialogHandler mDialogHandler;
+ private BluetoothLeBroadcastAssistant.Callback mAssistantCallback =
+ new BluetoothLeBroadcastAssistant.Callback() {
+ @Override
+ public void onSearchStarted(int reason) {}
- public AvailableMediaDeviceGroupController(
- Context context,
- @Nullable DashboardFragment fragment,
- @Nullable Lifecycle lifecycle) {
+ @Override
+ public void onSearchStartFailed(int reason) {}
+
+ @Override
+ public void onSearchStopped(int reason) {}
+
+ @Override
+ public void onSearchStopFailed(int reason) {}
+
+ @Override
+ public void onSourceFound(@NonNull BluetoothLeBroadcastMetadata source) {}
+
+ @Override
+ public void onSourceAdded(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceAddFailed(
+ @NonNull BluetoothDevice sink,
+ @NonNull BluetoothLeBroadcastMetadata source,
+ int reason) {}
+
+ @Override
+ public void onSourceModified(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceModifyFailed(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceRemoved(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {
+ Log.d(TAG, "onSourceRemoved: update media device list.");
+ if (mBluetoothDeviceUpdater != null) {
+ mBluetoothDeviceUpdater.forceUpdate();
+ }
+ }
+
+ @Override
+ public void onSourceRemoveFailed(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onReceiveStateChanged(
+ @NonNull BluetoothDevice sink,
+ int sourceId,
+ @NonNull BluetoothLeBroadcastReceiveState state) {
+ if (BluetoothUtils.isConnected(state)) {
+ Log.d(TAG, "onReceiveStateChanged: synced, update media device list.");
+ if (mBluetoothDeviceUpdater != null) {
+ mBluetoothDeviceUpdater.forceUpdate();
+ }
+ }
+ }
+ };
+
+ public AvailableMediaDeviceGroupController(Context context) {
super(context, KEY);
- if (fragment != null) {
- init(fragment);
- }
- if (lifecycle != null) {
- lifecycle.addObserver(this);
- }
mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
+ mExecutor = Executors.newSingleThreadExecutor();
}
@Override
@@ -82,6 +146,21 @@
Log.e(TAG, "onStart() Bluetooth is not supported on this device");
return;
}
+ if (AudioSharingUtils.isFeatureEnabled()) {
+ LocalBluetoothLeBroadcastAssistant assistant =
+ mLocalBluetoothManager
+ .getProfileManager()
+ .getLeAudioBroadcastAssistantProfile();
+ if (assistant != null) {
+ if (DEBUG) {
+ Log.d(TAG, "onStart() Register callbacks for assistant.");
+ }
+ assistant.registerServiceCallBack(mExecutor, mAssistantCallback);
+ }
+ if (mDialogHandler != null) {
+ mDialogHandler.registerCallbacks(mExecutor);
+ }
+ }
mLocalBluetoothManager.getEventManager().registerCallback(this);
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.registerCallback();
@@ -95,6 +174,21 @@
Log.e(TAG, "onStop() Bluetooth is not supported on this device");
return;
}
+ if (AudioSharingUtils.isFeatureEnabled()) {
+ LocalBluetoothLeBroadcastAssistant assistant =
+ mLocalBluetoothManager
+ .getProfileManager()
+ .getLeAudioBroadcastAssistantProfile();
+ if (assistant != null) {
+ if (DEBUG) {
+ Log.d(TAG, "onStop() Register callbacks for assistant.");
+ }
+ assistant.unregisterServiceCallBack(mAssistantCallback);
+ }
+ if (mDialogHandler != null) {
+ mDialogHandler.unregisterCallbacks();
+ }
+ }
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.unregisterCallback();
}
@@ -155,7 +249,11 @@
public void onDeviceClick(Preference preference) {
final CachedBluetoothDevice cachedDevice =
((BluetoothDevicePreference) preference).getBluetoothDevice();
- cachedDevice.setActive();
+ if (AudioSharingUtils.isFeatureEnabled() && mDialogHandler != null) {
+ mDialogHandler.handleDeviceConnected(cachedDevice, /* userTriggered= */ true);
+ } else {
+ cachedDevice.setActive();
+ }
}
public void init(DashboardFragment fragment) {
@@ -165,6 +263,9 @@
fragment.getContext(),
AvailableMediaDeviceGroupController.this,
fragment.getMetricsCategory());
+ if (AudioSharingUtils.isFeatureEnabled()) {
+ mDialogHandler = new AudioSharingDialogHandler(mContext, fragment);
+ }
}
@VisibleForTesting
@@ -177,6 +278,11 @@
mBluetoothDeviceUpdater = bluetoothDeviceUpdater;
}
+ @VisibleForTesting
+ public void setDialogHandler(AudioSharingDialogHandler dialogHandler) {
+ mDialogHandler = dialogHandler;
+ }
+
@Override
public void onAudioModeChanged() {
updateTitle();
diff --git a/src/com/android/settings/connecteddevice/ConnectedDeviceDashboardFragment.java b/src/com/android/settings/connecteddevice/ConnectedDeviceDashboardFragment.java
index 04ba5d2..27001d6 100644
--- a/src/com/android/settings/connecteddevice/ConnectedDeviceDashboardFragment.java
+++ b/src/com/android/settings/connecteddevice/ConnectedDeviceDashboardFragment.java
@@ -22,12 +22,13 @@
import android.text.TextUtils;
import android.util.Log;
-import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.settings.R;
import com.android.settings.SettingsActivity;
import com.android.settings.Utils;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingDevicePreferenceController;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settings.core.SettingsUIDeviceConfig;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settings.overlay.FeatureFactory;
@@ -35,13 +36,8 @@
import com.android.settings.search.BaseSearchIndexProvider;
import com.android.settings.slices.SlicePreferenceController;
import com.android.settingslib.bluetooth.HearingAidStatsLogUtils;
-import com.android.settingslib.core.AbstractPreferenceController;
-import com.android.settingslib.core.lifecycle.Lifecycle;
import com.android.settingslib.search.SearchIndexable;
-import java.util.ArrayList;
-import java.util.List;
-
@SearchIndexable(forTarget = SearchIndexable.ALL & ~SearchIndexable.ARC)
public class ConnectedDeviceDashboardFragment extends DashboardFragment {
@@ -91,6 +87,10 @@
+ ", action : "
+ action);
}
+ if (AudioSharingUtils.isFeatureEnabled()) {
+ use(AudioSharingDevicePreferenceController.class).init(this);
+ }
+ use(AvailableMediaDeviceGroupController.class).init(this);
use(ConnectedDeviceGroupController.class).init(this);
use(PreviouslyConnectedDevicePreferenceController.class).init(this);
use(SlicePreferenceController.class)
@@ -112,31 +112,6 @@
}
}
- @Override
- protected List<AbstractPreferenceController> createPreferenceControllers(Context context) {
- return buildPreferenceControllers(context, /* fragment= */ this, getSettingsLifecycle());
- }
-
- private static List<AbstractPreferenceController> buildPreferenceControllers(
- Context context,
- @Nullable ConnectedDeviceDashboardFragment fragment,
- @Nullable Lifecycle lifecycle) {
- final List<AbstractPreferenceController> controllers = new ArrayList<>();
- AbstractPreferenceController availableMediaController =
- FeatureFactory.getFeatureFactory()
- .getAudioSharingFeatureProvider()
- .createAvailableMediaDeviceGroupController(context, fragment, lifecycle);
- controllers.add(availableMediaController);
- AbstractPreferenceController audioSharingController =
- FeatureFactory.getFeatureFactory()
- .getAudioSharingFeatureProvider()
- .createAudioSharingDevicePreferenceController(context, fragment, lifecycle);
- if (audioSharingController != null) {
- controllers.add(audioSharingController);
- }
- return controllers;
- }
-
@VisibleForTesting
boolean isAlwaysDiscoverable(String callingAppPackageName, String action) {
return TextUtils.equals(SLICE_ACTION, action)
@@ -147,12 +122,5 @@
/** For Search. */
public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER =
- new BaseSearchIndexProvider(R.xml.connected_devices) {
- @Override
- public List<AbstractPreferenceController> createPreferenceControllers(
- Context context) {
- return buildPreferenceControllers(
- context, /* fragment= */ null, /* lifecycle= */ null);
- }
- };
+ new BaseSearchIndexProvider(R.xml.connected_devices);
}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingActivity.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingActivity.java
new file mode 100644
index 0000000..1ec53f9
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingActivity.java
@@ -0,0 +1,36 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import android.os.Bundle;
+
+import com.android.settings.SettingsActivity;
+
+public class AudioSharingActivity extends SettingsActivity {
+ @Override
+ protected void onCreate(Bundle savedState) {
+ super.onCreate(savedState);
+ if (!AudioSharingUtils.isFeatureEnabled()) {
+ finish();
+ }
+ }
+
+ @Override
+ protected boolean isValidFragment(String fragmentName) {
+ return AudioSharingDashboardFragment.class.getName().equals(fragmentName);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingBasePreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingBasePreferenceController.java
new file mode 100644
index 0000000..e933e41
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingBasePreferenceController.java
@@ -0,0 +1,127 @@
+/*
+ * 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.bluetooth.BluetoothAdapter;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+public abstract class AudioSharingBasePreferenceController extends BasePreferenceController
+ implements DefaultLifecycleObserver {
+ private static final String TAG = "AudioSharingBasePreferenceController";
+
+ private final BluetoothAdapter mBluetoothAdapter;
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final LocalBluetoothProfileManager mProfileManager;
+ @Nullable protected final LocalBluetoothLeBroadcast mBroadcast;
+ @Nullable protected Preference mPreference;
+
+ public AudioSharingBasePreferenceController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+ mBtManager = Utils.getLocalBtManager(context);
+ mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
+ mBroadcast = mProfileManager == null ? null : mProfileManager.getLeAudioBroadcastProfile();
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mPreference = screen.findPreference(getPreferenceKey());
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ updateVisibility();
+ }
+
+ /** Update the visibility of the preference. */
+ protected void updateVisibility() {
+ if (mPreference == null) {
+ Log.d(TAG, "Skip updateVisibility, null preference");
+ return;
+ }
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ if (!isAvailable()) {
+ Log.w(TAG, "Skip updateVisibility, unavailable preference");
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> { // Check nullability to pass NullAway check
+ if (mPreference != null) {
+ mPreference.setVisible(false);
+ }
+ });
+ return;
+ }
+ boolean isBtOn = isBluetoothStateOn();
+ boolean isProfileReady =
+ AudioSharingUtils.isAudioSharingProfileReady(mProfileManager);
+ boolean isBroadcasting = isBroadcasting();
+ boolean isVisible = isBtOn && isProfileReady && isBroadcasting;
+ Log.d(
+ TAG,
+ "updateVisibility, isBtOn = "
+ + isBtOn
+ + ", isProfileReady = "
+ + isProfileReady
+ + ", isBroadcasting = "
+ + isBroadcasting);
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> { // Check nullability to pass NullAway check
+ if (mPreference != null) {
+ mPreference.setVisible(isVisible);
+ }
+ });
+ });
+ }
+
+ /**
+ * Triggered when {@link AudioSharingDashboardFragment} receive onAudioSharingProfilesConnected
+ * callbacks.
+ */
+ protected void onAudioSharingProfilesConnected() {}
+
+ protected boolean isBroadcasting() {
+ return mBroadcast != null && mBroadcast.isEnabled(null);
+ }
+
+ protected boolean isBluetoothStateOn() {
+ return mBluetoothAdapter != null && mBluetoothAdapter.isEnabled();
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingBluetoothDeviceUpdater.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingBluetoothDeviceUpdater.java
new file mode 100644
index 0000000..50517fb
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingBluetoothDeviceUpdater.java
@@ -0,0 +1,91 @@
+/*
+ * 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.content.Context;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+
+import com.android.settings.bluetooth.BluetoothDeviceUpdater;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+
+public class AudioSharingBluetoothDeviceUpdater extends BluetoothDeviceUpdater
+ implements Preference.OnPreferenceClickListener {
+
+ private static final String TAG = "AudioSharingBluetoothDeviceUpdater";
+
+ private static final String PREF_KEY = "audio_sharing_bt";
+
+ @Nullable private LocalBluetoothManager mLocalBtManager;
+
+ public AudioSharingBluetoothDeviceUpdater(
+ Context context,
+ DevicePreferenceCallback devicePreferenceCallback,
+ int metricsCategory) {
+ super(context, devicePreferenceCallback, metricsCategory);
+ mLocalBtManager = Utils.getLocalBluetoothManager(context);
+ }
+
+ @Override
+ public boolean isFilterMatched(CachedBluetoothDevice cachedDevice) {
+ boolean isFilterMatched = false;
+ if (isDeviceConnected(cachedDevice) && isDeviceInCachedDevicesList(cachedDevice)) {
+ // If device is LE audio device and has a broadcast source,
+ // it would show in audio sharing devices group.
+ if (AudioSharingUtils.isFeatureEnabled()
+ && cachedDevice.isConnectedLeAudioDevice()
+ && BluetoothUtils.hasConnectedBroadcastSource(cachedDevice, mLocalBtManager)) {
+ isFilterMatched = true;
+ }
+ }
+ Log.d(
+ TAG,
+ "isFilterMatched() device : "
+ + cachedDevice.getName()
+ + ", isFilterMatched : "
+ + isFilterMatched);
+ return isFilterMatched;
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ mMetricsFeatureProvider.logClickedPreference(preference, mMetricsCategory);
+ return true;
+ }
+
+ @Override
+ protected String getPreferenceKey() {
+ return PREF_KEY;
+ }
+
+ @Override
+ protected String getLogTag() {
+ return TAG;
+ }
+
+ @Override
+ protected void update(CachedBluetoothDevice cachedBluetoothDevice) {
+ super.update(cachedBluetoothDevice);
+ Log.d(TAG, "Map : " + mPreferenceMap);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceController.java
new file mode 100644
index 0000000..84b769d
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceController.java
@@ -0,0 +1,252 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import android.bluetooth.BluetoothLeBroadcast;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.PreferenceScreen;
+import androidx.preference.TwoStatePreference;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.TogglePreferenceController;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class AudioSharingCompatibilityPreferenceController extends TogglePreferenceController
+ implements DefaultLifecycleObserver, LocalBluetoothProfileManager.ServiceListener {
+
+ private static final String TAG = "AudioSharingCompatibilityPrefController";
+
+ private static final String PREF_KEY = "audio_sharing_stream_compatibility";
+
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final LocalBluetoothProfileManager mProfileManager;
+ @Nullable private final LocalBluetoothLeBroadcast mBroadcast;
+ @Nullable private TwoStatePreference mPreference;
+ private final Executor mExecutor;
+ private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false);
+
+ private final BluetoothLeBroadcast.Callback mBroadcastCallback =
+ new BluetoothLeBroadcast.Callback() {
+ @Override
+ public void onBroadcastStarted(int reason, int broadcastId) {
+ Log.d(
+ TAG,
+ "onBroadcastStarted(), reason = "
+ + reason
+ + ", broadcastId = "
+ + broadcastId);
+ updateEnabled();
+ }
+
+ @Override
+ public void onBroadcastStartFailed(int reason) {}
+
+ @Override
+ public void onBroadcastMetadataChanged(
+ int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) {}
+
+ @Override
+ public void onBroadcastStopped(int reason, int broadcastId) {
+ Log.d(
+ TAG,
+ "onBroadcastStopped(), reason = "
+ + reason
+ + ", broadcastId = "
+ + broadcastId);
+ updateEnabled();
+ }
+
+ @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) {}
+ };
+
+ public AudioSharingCompatibilityPreferenceController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ mBtManager = Utils.getLocalBtManager(context);
+ mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
+ mBroadcast = mProfileManager == null ? null : mProfileManager.getLeAudioBroadcastProfile();
+ mExecutor = Executors.newSingleThreadExecutor();
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip register callbacks, feature not support");
+ return;
+ }
+ if (!AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ Log.d(TAG, "Skip register callbacks, profile not ready");
+ if (mProfileManager != null) {
+ mProfileManager.addServiceListener(this);
+ }
+ return;
+ }
+ registerCallbacks();
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip unregister callbacks, feature not support");
+ return;
+ }
+ if (mProfileManager != null) {
+ mProfileManager.removeServiceListener(this);
+ }
+ if (mBroadcast == null || !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ Log.d(TAG, "Skip unregister callbacks, profile not ready");
+ return;
+ }
+ if (mCallbacksRegistered.get()) {
+ Log.d(TAG, "Unregister callbacks");
+ mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
+ mCallbacksRegistered.set(false);
+ }
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mPreference = screen.findPreference(getPreferenceKey());
+ updateEnabled();
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return PREF_KEY;
+ }
+
+ @Override
+ public boolean isChecked() {
+ return mBroadcast != null && mBroadcast.getImproveCompatibility();
+ }
+
+ @Override
+ public boolean setChecked(boolean isChecked) {
+ if (mBroadcast == null || mBroadcast.getImproveCompatibility() == isChecked) {
+ if (mBroadcast != null) {
+ Log.d(TAG, "Skip setting improveCompatibility, unchanged");
+ }
+ return false;
+ }
+ mBroadcast.setImproveCompatibility(isChecked);
+ // TODO: call updateBroadcast once framework change ready.
+ return true;
+ }
+
+ @Override
+ public void onServiceConnected() {
+ if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ registerCallbacks();
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mPreference != null) {
+ updateState(mPreference);
+ }
+ updateEnabled();
+ });
+ if (mProfileManager != null) {
+ mProfileManager.removeServiceListener(this);
+ }
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected() {
+ // Do nothing
+ }
+
+ @Override
+ public int getSliceHighlightMenuRes() {
+ return 0;
+ }
+
+ /** Test only: set callbacks registration state for test setup. */
+ @VisibleForTesting
+ public void setCallbacksRegistered(boolean registered) {
+ mCallbacksRegistered.set(registered);
+ }
+
+ private void registerCallbacks() {
+ if (mBroadcast == null) {
+ Log.d(TAG, "Skip register callbacks, profile not ready");
+ return;
+ }
+ if (!mCallbacksRegistered.get()) {
+ Log.d(TAG, "Register callbacks");
+ mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
+ mCallbacksRegistered.set(true);
+ }
+ }
+
+ private void updateEnabled() {
+ int disabledDescriptionRes =
+ R.string.audio_sharing_stream_compatibility_disabled_description;
+ int descriptionRes = R.string.audio_sharing_stream_compatibility_description;
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ boolean isBroadcasting = AudioSharingUtils.isBroadcasting(mBtManager);
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mPreference != null) {
+ mPreference.setEnabled(!isBroadcasting);
+ mPreference.setSummary(
+ isBroadcasting
+ ? mContext.getString(
+ disabledDescriptionRes)
+ : mContext.getString(descriptionRes));
+ }
+ });
+ });
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingConfirmDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingConfirmDialogFragment.java
new file mode 100644
index 0000000..9dd466d
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingConfirmDialogFragment.java
@@ -0,0 +1,71 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.android.settings.R;
+import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
+
+public class AudioSharingConfirmDialogFragment extends InstrumentedDialogFragment {
+ private static final String TAG = "AudioSharingConfirmDialog";
+
+ @Override
+ public int getMetricsCategory() {
+ // TODO: add metrics category.
+ return 0;
+ }
+
+ /**
+ * Display the {@link AudioSharingConfirmDialogFragment} dialog.
+ *
+ * @param host The Fragment this dialog will be hosted.
+ */
+ public static void show(Fragment host) {
+ if (!AudioSharingUtils.isFeatureEnabled()) return;
+ FragmentManager manager = host.getChildFragmentManager();
+ AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG);
+ if (dialog != null) {
+ Log.d(TAG, "Dialog is showing, return.");
+ return;
+ }
+ Log.d(TAG, "Show up the confirm dialog.");
+ AudioSharingConfirmDialogFragment dialogFrag = new AudioSharingConfirmDialogFragment();
+ dialogFrag.show(manager, TAG);
+ }
+
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ AlertDialog dialog =
+ AudioSharingDialogFactory.newBuilder(getActivity())
+ .setTitle(R.string.audio_sharing_confirm_dialog_title)
+ .setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
+ .setIsCustomBodyEnabled(true)
+ .setCustomMessage(R.string.audio_sharing_comfirm_dialog_content)
+ .setPositiveButton(com.android.settings.R.string.okay, (d, w) -> dismiss())
+ .build();
+ dialog.setCanceledOnTouchOutside(true);
+ return dialog;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragment.java
new file mode 100644
index 0000000..275d197
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragment.java
@@ -0,0 +1,111 @@
+/*
+ * 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.settings.SettingsEnums;
+import android.content.Context;
+import android.os.Bundle;
+
+import com.android.settings.R;
+import com.android.settings.SettingsActivity;
+import com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsCategoryController;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settings.widget.SettingsMainSwitchBar;
+
+public class AudioSharingDashboardFragment extends DashboardFragment
+ implements AudioSharingSwitchBarController.OnAudioSharingStateChangedListener {
+ private static final String TAG = "AudioSharingDashboardFrag";
+
+ SettingsMainSwitchBar mMainSwitchBar;
+ private AudioSharingSwitchBarController mSwitchBarController;
+ private AudioSharingDeviceVolumeGroupController mAudioSharingDeviceVolumeGroupController;
+ private CallsAndAlarmsPreferenceController mCallsAndAlarmsPreferenceController;
+ private AudioSharingPlaySoundPreferenceController mAudioSharingPlaySoundPreferenceController;
+ private AudioStreamsCategoryController mAudioStreamsCategoryController;
+
+ public AudioSharingDashboardFragment() {
+ super();
+ }
+
+ @Override
+ public int getMetricsCategory() {
+ return SettingsEnums.AUDIO_SHARING_SETTINGS;
+ }
+
+ @Override
+ protected String getLogTag() {
+ return TAG;
+ }
+
+ @Override
+ public int getHelpResource() {
+ return R.string.help_url_audio_sharing;
+ }
+
+ @Override
+ protected int getPreferenceScreenResId() {
+ return R.xml.bluetooth_le_audio_sharing;
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ mAudioSharingDeviceVolumeGroupController =
+ use(AudioSharingDeviceVolumeGroupController.class);
+ mAudioSharingDeviceVolumeGroupController.init(this);
+ mCallsAndAlarmsPreferenceController = use(CallsAndAlarmsPreferenceController.class);
+ mCallsAndAlarmsPreferenceController.init(this);
+ mAudioSharingPlaySoundPreferenceController =
+ use(AudioSharingPlaySoundPreferenceController.class);
+ mAudioStreamsCategoryController = use(AudioStreamsCategoryController.class);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ // Assume we are in a SettingsActivity. This is only safe because we currently use
+ // SettingsActivity as base for all preference fragments.
+ final SettingsActivity activity = (SettingsActivity) getActivity();
+ mMainSwitchBar = activity.getSwitchBar();
+ mMainSwitchBar.setTitle(getText(R.string.audio_sharing_switch_title));
+ mSwitchBarController = new AudioSharingSwitchBarController(activity, mMainSwitchBar, this);
+ mSwitchBarController.init(this);
+ getSettingsLifecycle().addObserver(mSwitchBarController);
+ mMainSwitchBar.show();
+ }
+
+ @Override
+ public void onAudioSharingStateChanged() {
+ updateVisibilityForAttachedPreferences();
+ }
+
+ @Override
+ public void onAudioSharingProfilesConnected() {
+ onProfilesConnectedForAttachedPreferences();
+ }
+
+ private void updateVisibilityForAttachedPreferences() {
+ mAudioSharingDeviceVolumeGroupController.updateVisibility();
+ mCallsAndAlarmsPreferenceController.updateVisibility();
+ mAudioSharingPlaySoundPreferenceController.updateVisibility();
+ mAudioStreamsCategoryController.updateVisibility();
+ }
+
+ private void onProfilesConnectedForAttachedPreferences() {
+ mAudioSharingDeviceVolumeGroupController.onAudioSharingProfilesConnected();
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceAdapter.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceAdapter.java
new file mode 100644
index 0000000..0b6b8c9
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceAdapter.java
@@ -0,0 +1,116 @@
+/*
+ * 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.content.Context;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.settings.R;
+
+import java.util.List;
+
+public class AudioSharingDeviceAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
+
+ private static final String TAG = "AudioSharingDeviceAdapter";
+
+ private final Context mContext;
+ private final List<AudioSharingDeviceItem> mDevices;
+ private final OnClickListener mOnClickListener;
+ private final ActionType mType;
+
+ public AudioSharingDeviceAdapter(
+ @NonNull Context context,
+ @NonNull List<AudioSharingDeviceItem> devices,
+ @NonNull OnClickListener listener,
+ @NonNull ActionType type) {
+ mContext = context;
+ mDevices = devices;
+ mOnClickListener = listener;
+ mType = type;
+ }
+
+ /**
+ * The action type when user click on the item.
+ *
+ * <p>We choose the item text based on this type.
+ */
+ public enum ActionType {
+ // Click on the item will add the item to audio sharing
+ SHARE,
+ // Click on the item will remove the item from audio sharing
+ REMOVE,
+ }
+
+ private class AudioSharingDeviceViewHolder extends RecyclerView.ViewHolder {
+ private final Button mButtonView;
+
+ AudioSharingDeviceViewHolder(View view) {
+ super(view);
+ mButtonView = view.findViewById(R.id.device_button);
+ }
+
+ public void bindView(int position) {
+ if (mButtonView != null) {
+ String btnText = switch (mType) {
+ case SHARE ->
+ mContext.getString(
+ R.string.audio_sharing_share_with_button_label,
+ mDevices.get(position).getName());
+ case REMOVE ->
+ mContext.getString(
+ R.string.audio_sharing_disconnect_device_button_label,
+ mDevices.get(position).getName());
+ };
+ mButtonView.setText(btnText);
+ mButtonView.setOnClickListener(
+ v -> mOnClickListener.onClick(mDevices.get(position)));
+ } else {
+ Log.w(TAG, "bind view skipped due to button view is null");
+ }
+ }
+ }
+
+ @Override
+ public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ View view =
+ LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.audio_sharing_device_item, parent, false);
+ return new AudioSharingDeviceViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
+ ((AudioSharingDeviceViewHolder) holder).bindView(position);
+ }
+
+ @Override
+ public int getItemCount() {
+ return mDevices.size();
+ }
+
+ public interface OnClickListener {
+ /** Called when an item has been clicked. */
+ void onClick(AudioSharingDeviceItem item);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceItem.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceItem.java
new file mode 100644
index 0000000..5998e30
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceItem.java
@@ -0,0 +1,75 @@
+/*
+ * 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.os.Parcel;
+import android.os.Parcelable;
+
+public final class AudioSharingDeviceItem implements Parcelable {
+ private final String mName;
+ private final int mGroupId;
+ private final boolean mIsActive;
+
+ public AudioSharingDeviceItem(String name, int groupId, boolean isActive) {
+ mName = name;
+ mGroupId = groupId;
+ mIsActive = isActive;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public int getGroupId() {
+ return mGroupId;
+ }
+
+ public boolean isActive() {
+ return mIsActive;
+ }
+
+ public AudioSharingDeviceItem(Parcel in) {
+ mName = in.readString();
+ mGroupId = in.readInt();
+ mIsActive = in.readBoolean();
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(mName);
+ dest.writeInt(mGroupId);
+ dest.writeBoolean(mIsActive);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public static final Creator<AudioSharingDeviceItem> CREATOR =
+ new Creator<AudioSharingDeviceItem>() {
+ @Override
+ public AudioSharingDeviceItem createFromParcel(Parcel in) {
+ return new AudioSharingDeviceItem(in);
+ }
+
+ @Override
+ public AudioSharingDeviceItem[] newArray(int size) {
+ return new AudioSharingDeviceItem[size];
+ }
+ };
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDevicePreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDevicePreferenceController.java
new file mode 100644
index 0000000..51a8e11
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDevicePreferenceController.java
@@ -0,0 +1,486 @@
+/*
+ * 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 static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast.EXTRA_BLUETOOTH_DEVICE;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceGroup;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.SettingsActivity;
+import com.android.settings.bluetooth.BluetoothDeviceUpdater;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settingslib.bluetooth.A2dpProfile;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.BluetoothEventManager;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
+import com.android.settingslib.bluetooth.HeadsetProfile;
+import com.android.settingslib.bluetooth.HearingAidProfile;
+import com.android.settingslib.bluetooth.LeAudioProfile;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+
+import java.util.Locale;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class AudioSharingDevicePreferenceController extends BasePreferenceController
+ implements DefaultLifecycleObserver,
+ DevicePreferenceCallback,
+ BluetoothCallback,
+ LocalBluetoothProfileManager.ServiceListener {
+ private static final boolean DEBUG = BluetoothUtils.D;
+
+ private static final String TAG = "AudioSharingDevicePrefController";
+ private static final String KEY = "audio_sharing_device_list";
+ private static final String KEY_AUDIO_SHARING_SETTINGS =
+ "connected_device_audio_sharing_settings";
+
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final CachedBluetoothDeviceManager mDeviceManager;
+ @Nullable private final BluetoothEventManager mEventManager;
+ @Nullable private final LocalBluetoothProfileManager mProfileManager;
+ @Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
+ private final Executor mExecutor;
+ @Nullable private PreferenceGroup mPreferenceGroup;
+ @Nullable private Preference mAudioSharingSettingsPreference;
+ @Nullable private BluetoothDeviceUpdater mBluetoothDeviceUpdater;
+ @Nullable private DashboardFragment mFragment;
+ @Nullable private AudioSharingDialogHandler mDialogHandler;
+ private AtomicBoolean mIntentHandled = new AtomicBoolean(false);
+
+ private BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
+ new BluetoothLeBroadcastAssistant.Callback() {
+ @Override
+ public void onSearchStarted(int reason) {}
+
+ @Override
+ public void onSearchStartFailed(int reason) {}
+
+ @Override
+ public void onSearchStopped(int reason) {}
+
+ @Override
+ public void onSearchStopFailed(int reason) {}
+
+ @Override
+ public void onSourceFound(@NonNull BluetoothLeBroadcastMetadata source) {}
+
+ @Override
+ public void onSourceAdded(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceAddFailed(
+ @NonNull BluetoothDevice sink,
+ @NonNull BluetoothLeBroadcastMetadata source,
+ int reason) {
+ AudioSharingUtils.toastMessage(
+ mContext,
+ String.format(
+ Locale.US,
+ "Fail to add source to %s reason %d",
+ sink.getAddress(),
+ reason));
+ }
+
+ @Override
+ public void onSourceModified(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceModifyFailed(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceRemoved(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {
+ Log.d(TAG, "onSourceRemoved: update sharing device list.");
+ if (mBluetoothDeviceUpdater != null) {
+ mBluetoothDeviceUpdater.forceUpdate();
+ }
+ }
+
+ @Override
+ public void onSourceRemoveFailed(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {
+ AudioSharingUtils.toastMessage(
+ mContext,
+ String.format(
+ Locale.US,
+ "Fail to remove source from %s reason %d",
+ sink.getAddress(),
+ reason));
+ }
+
+ @Override
+ public void onReceiveStateChanged(
+ @NonNull BluetoothDevice sink,
+ int sourceId,
+ @NonNull BluetoothLeBroadcastReceiveState state) {
+ if (BluetoothUtils.isConnected(state)) {
+ Log.d(TAG, "onSourceAdded: update sharing device list.");
+ if (mBluetoothDeviceUpdater != null) {
+ mBluetoothDeviceUpdater.forceUpdate();
+ }
+ if (mDeviceManager != null && mDialogHandler != null) {
+ CachedBluetoothDevice cachedDevice = mDeviceManager.findDevice(sink);
+ if (cachedDevice != null) {
+ mDialogHandler.closeOpeningDialogsForLeaDevice(cachedDevice);
+ }
+ }
+ }
+ }
+ };
+
+ public AudioSharingDevicePreferenceController(Context context) {
+ super(context, KEY);
+ mBtManager = Utils.getLocalBtManager(mContext);
+ mEventManager = mBtManager == null ? null : mBtManager.getEventManager();
+ mDeviceManager = mBtManager == null ? null : mBtManager.getCachedDeviceManager();
+ mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
+ mAssistant =
+ mProfileManager == null
+ ? null
+ : mProfileManager.getLeAudioBroadcastAssistantProfile();
+ mExecutor = Executors.newSingleThreadExecutor();
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip onStart(), feature is not supported.");
+ return;
+ }
+ if (!AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)
+ && mProfileManager != null) {
+ Log.d(TAG, "Register profile service listener");
+ mProfileManager.addServiceListener(this);
+ }
+ if (mEventManager == null
+ || mAssistant == null
+ || mDialogHandler == null
+ || mBluetoothDeviceUpdater == null) {
+ Log.d(TAG, "Skip onStart(), profile is not ready.");
+ return;
+ }
+ Log.d(TAG, "onStart() Register callbacks.");
+ mEventManager.registerCallback(this);
+ mAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
+ mDialogHandler.registerCallbacks(mExecutor);
+ mBluetoothDeviceUpdater.registerCallback();
+ mBluetoothDeviceUpdater.refreshPreference();
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip onStop(), feature is not supported.");
+ return;
+ }
+ if (mProfileManager != null) {
+ mProfileManager.removeServiceListener(this);
+ }
+ if (mEventManager == null
+ || mAssistant == null
+ || mDialogHandler == null
+ || mBluetoothDeviceUpdater == null) {
+ Log.d(TAG, "Skip onStop(), profile is not ready.");
+ return;
+ }
+ Log.d(TAG, "onStop() Unregister callbacks.");
+ mEventManager.unregisterCallback(this);
+ mAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
+ mDialogHandler.unregisterCallbacks();
+ mBluetoothDeviceUpdater.unregisterCallback();
+ }
+
+ @Override
+ public void onServiceConnected() {
+ if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ if (mProfileManager != null) {
+ mProfileManager.removeServiceListener(this);
+ }
+ if (!mIntentHandled.get()) {
+ Log.d(TAG, "onServiceConnected: handleDeviceClickFromIntent");
+ handleDeviceClickFromIntent();
+ mIntentHandled.set(true);
+ }
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected() {
+ // Do nothing
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mPreferenceGroup = screen.findPreference(KEY);
+ if (mPreferenceGroup != null) {
+ mAudioSharingSettingsPreference =
+ mPreferenceGroup.findPreference(KEY_AUDIO_SHARING_SETTINGS);
+ mPreferenceGroup.setVisible(false);
+ }
+ if (mAudioSharingSettingsPreference != null) {
+ mAudioSharingSettingsPreference.setVisible(false);
+ }
+
+ if (isAvailable()) {
+ if (mBluetoothDeviceUpdater != null) {
+ mBluetoothDeviceUpdater.setPrefContext(screen.getContext());
+ mBluetoothDeviceUpdater.forceUpdate();
+ }
+ if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ if (!mIntentHandled.get()) {
+ Log.d(TAG, "displayPreference: profile ready, handleDeviceClickFromIntent");
+ handleDeviceClickFromIntent();
+ mIntentHandled.set(true);
+ }
+ }
+ }
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AudioSharingUtils.isFeatureEnabled() && mBluetoothDeviceUpdater != null
+ ? AVAILABLE_UNSEARCHABLE
+ : UNSUPPORTED_ON_DEVICE;
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return KEY;
+ }
+
+ @Override
+ public void onDeviceAdded(Preference preference) {
+ if (mPreferenceGroup != null) {
+ if (mPreferenceGroup.getPreferenceCount() == 1) {
+ mPreferenceGroup.setVisible(true);
+ if (mAudioSharingSettingsPreference != null) {
+ mAudioSharingSettingsPreference.setVisible(true);
+ }
+ }
+ mPreferenceGroup.addPreference(preference);
+ }
+ }
+
+ @Override
+ public void onDeviceRemoved(Preference preference) {
+ if (mPreferenceGroup != null) {
+ mPreferenceGroup.removePreference(preference);
+ if (mPreferenceGroup.getPreferenceCount() == 1) {
+ mPreferenceGroup.setVisible(false);
+ if (mAudioSharingSettingsPreference != null) {
+ mAudioSharingSettingsPreference.setVisible(false);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onProfileConnectionStateChanged(
+ @NonNull CachedBluetoothDevice cachedDevice,
+ @ConnectionState int state,
+ int bluetoothProfile) {
+ if (mDialogHandler == null || mAssistant == null || mFragment == null) {
+ Log.d(TAG, "Ignore onProfileConnectionStateChanged, not init correctly");
+ return;
+ }
+ if (!isMediaDevice(cachedDevice)) {
+ Log.d(TAG, "Ignore onProfileConnectionStateChanged, not a media device");
+ return;
+ }
+ // Close related dialogs if the BT remote device is disconnected.
+ if (state == BluetoothAdapter.STATE_DISCONNECTED) {
+ boolean isLeAudioSupported = AudioSharingUtils.isLeAudioSupported(cachedDevice);
+ if (isLeAudioSupported
+ && bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) {
+ mDialogHandler.closeOpeningDialogsForLeaDevice(cachedDevice);
+ return;
+ }
+ if (!isLeAudioSupported && !cachedDevice.isConnected()) {
+ mDialogHandler.closeOpeningDialogsForNonLeaDevice(cachedDevice);
+ return;
+ }
+ }
+ if (state != BluetoothAdapter.STATE_CONNECTED || !cachedDevice.getDevice().isConnected()) {
+ Log.d(TAG, "Ignore onProfileConnectionStateChanged, not connected state");
+ return;
+ }
+ handleOnProfileStateChanged(cachedDevice, bluetoothProfile);
+ }
+
+ /**
+ * Initialize the controller.
+ *
+ * @param fragment The fragment to provide the context and metrics category for {@link
+ * AudioSharingBluetoothDeviceUpdater} and provide the host for dialogs.
+ */
+ public void init(DashboardFragment fragment) {
+ mFragment = fragment;
+ mBluetoothDeviceUpdater =
+ new AudioSharingBluetoothDeviceUpdater(
+ fragment.getContext(),
+ AudioSharingDevicePreferenceController.this,
+ fragment.getMetricsCategory());
+ mDialogHandler = new AudioSharingDialogHandler(mContext, fragment);
+ }
+
+ @VisibleForTesting
+ public void setBluetoothDeviceUpdater(@Nullable BluetoothDeviceUpdater bluetoothDeviceUpdater) {
+ mBluetoothDeviceUpdater = bluetoothDeviceUpdater;
+ }
+
+ @VisibleForTesting
+ public void setDialogHandler(@Nullable AudioSharingDialogHandler dialogHandler) {
+ mDialogHandler = dialogHandler;
+ }
+
+ @VisibleForTesting
+ public void setHostFragment(@Nullable DashboardFragment fragment) {
+ mFragment = fragment;
+ }
+
+ /** Test only: set intent handle state for test. */
+ @VisibleForTesting
+ public void setIntentHandled(boolean handled) {
+ mIntentHandled.set(handled);
+ }
+
+ private void handleOnProfileStateChanged(
+ @NonNull CachedBluetoothDevice cachedDevice, int bluetoothProfile) {
+ boolean isLeAudioSupported = AudioSharingUtils.isLeAudioSupported(cachedDevice);
+ // For eligible (LE audio) remote device, we only check its connected LE audio assistant
+ // profile.
+ if (isLeAudioSupported
+ && bluetoothProfile != BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) {
+ Log.d(
+ TAG,
+ "Ignore onProfileConnectionStateChanged, not the le assistant 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 (DEBUG) {
+ Log.d(
+ TAG,
+ "Start handling onProfileConnectionStateChanged for "
+ + cachedDevice.getDevice().getAnonymizedAddress());
+ }
+ // Check nullability to pass NullAway check
+ if (mDialogHandler != null) {
+ mDialogHandler.handleDeviceConnected(cachedDevice, /* userTriggered= */ false);
+ }
+ }
+
+ private boolean isMediaDevice(CachedBluetoothDevice cachedDevice) {
+ return cachedDevice.getConnectableProfiles().stream()
+ .anyMatch(
+ profile ->
+ profile instanceof A2dpProfile
+ || profile instanceof HearingAidProfile
+ || profile instanceof LeAudioProfile
+ || profile instanceof HeadsetProfile);
+ }
+
+ private boolean isFirstConnectedProfile(
+ CachedBluetoothDevice cachedDevice, int bluetoothProfile) {
+ return cachedDevice.getProfiles().stream()
+ .noneMatch(
+ profile ->
+ profile.getProfileId() != bluetoothProfile
+ && profile.getConnectionStatus(cachedDevice.getDevice())
+ == BluetoothProfile.STATE_CONNECTED);
+ }
+
+ /**
+ * Handle device click triggered by intent.
+ *
+ * <p>When user click device from BT QS dialog, BT QS will send intent to open {@link
+ * com.android.settings.connecteddevice.ConnectedDeviceDashboardFragment} and handle device
+ * click event under some conditions.
+ *
+ * <p>This method will be called when displayPreference if the audio sharing profiles are ready.
+ * If the profiles are not ready when the preference display, this method will be called when
+ * onServiceConnected.
+ */
+ private void handleDeviceClickFromIntent() {
+ if (mFragment == null
+ || mFragment.getActivity() == null
+ || mFragment.getActivity().getIntent() == null) {
+ Log.d(TAG, "Skip handleDeviceClickFromIntent, fragment intent is null");
+ return;
+ }
+ Intent intent = mFragment.getActivity().getIntent();
+ Bundle args = intent.getBundleExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS);
+ BluetoothDevice device =
+ args == null
+ ? null
+ : args.getParcelable(EXTRA_BLUETOOTH_DEVICE, BluetoothDevice.class);
+ CachedBluetoothDevice cachedDevice =
+ (device == null || mDeviceManager == null)
+ ? null
+ : mDeviceManager.findDevice(device);
+ if (cachedDevice == null) {
+ Log.d(TAG, "Skip handleDeviceClickFromIntent, device is null");
+ return;
+ }
+ // Check nullability to pass NullAway check
+ if (device != null && !device.isConnected()) {
+ Log.d(TAG, "handleDeviceClickFromIntent: connect");
+ cachedDevice.connect();
+ } else if (mDialogHandler != null) {
+ Log.d(TAG, "handleDeviceClickFromIntent: trigger dialog handler");
+ mDialogHandler.handleDeviceConnected(cachedDevice, /* userTriggered= */ true);
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeControlUpdater.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeControlUpdater.java
new file mode 100644
index 0000000..257ae77
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeControlUpdater.java
@@ -0,0 +1,177 @@
+/*
+ * 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.bluetooth.BluetoothCsipSetCoordinator;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.media.AudioManager;
+import android.util.Log;
+import android.widget.SeekBar;
+
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+
+import com.android.settings.bluetooth.BluetoothDevicePreference;
+import com.android.settings.bluetooth.BluetoothDeviceUpdater;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+
+public class AudioSharingDeviceVolumeControlUpdater extends BluetoothDeviceUpdater
+ implements Preference.OnPreferenceClickListener {
+
+ private static final String TAG = "AudioSharingDeviceVolumeControlUpdater";
+
+ private static final String PREF_KEY = "audio_sharing_volume_control";
+
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final VolumeControlProfile mVolumeControl;
+
+ public AudioSharingDeviceVolumeControlUpdater(
+ Context context,
+ DevicePreferenceCallback devicePreferenceCallback,
+ int metricsCategory) {
+ super(context, devicePreferenceCallback, metricsCategory);
+ mBtManager = Utils.getLocalBluetoothManager(context);
+ mVolumeControl =
+ mBtManager == null
+ ? null
+ : mBtManager.getProfileManager().getVolumeControlProfile();
+ }
+
+ @Override
+ public boolean isFilterMatched(CachedBluetoothDevice cachedDevice) {
+ boolean isFilterMatched = false;
+ if (isDeviceConnected(cachedDevice) && isDeviceInCachedDevicesList(cachedDevice)) {
+ // If device is LE audio device and in a sharing session on current sharing device,
+ // it would show in volume control group.
+ if (cachedDevice.isConnectedLeAudioDevice()
+ && AudioSharingUtils.isBroadcasting(mBtManager)
+ && BluetoothUtils.hasConnectedBroadcastSource(cachedDevice, mBtManager)) {
+ isFilterMatched = true;
+ }
+ }
+ Log.d(
+ TAG,
+ "isFilterMatched() device : "
+ + cachedDevice.getName()
+ + ", isFilterMatched : "
+ + isFilterMatched);
+ return isFilterMatched;
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ return true;
+ }
+
+ @Override
+ protected void addPreference(CachedBluetoothDevice cachedDevice) {
+ if (cachedDevice == null) return;
+ final BluetoothDevice device = cachedDevice.getDevice();
+ if (!mPreferenceMap.containsKey(device)) {
+ SeekBar.OnSeekBarChangeListener listener =
+ new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(
+ SeekBar seekBar, int progress, boolean fromUser) {}
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {}
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ int progress = seekBar.getProgress();
+ int groupId = AudioSharingUtils.getGroupId(cachedDevice);
+ if (groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID
+ && groupId
+ == AudioSharingUtils.getFallbackActiveGroupId(
+ mContext)) {
+ // Set media stream volume for primary buds, audio manager will
+ // update all buds volume in the audio sharing.
+ setAudioManagerStreamVolume(progress);
+ } else {
+ // Set buds volume for other buds.
+ setDeviceVolume(cachedDevice, progress);
+ }
+ }
+ };
+ AudioSharingDeviceVolumePreference vPreference =
+ new AudioSharingDeviceVolumePreference(mPrefContext, cachedDevice);
+ vPreference.initialize();
+ vPreference.setOnSeekBarChangeListener(listener);
+ vPreference.setKey(getPreferenceKey());
+ vPreference.setIcon(com.android.settingslib.R.drawable.ic_bt_untethered_earbuds);
+ vPreference.setTitle(cachedDevice.getName());
+ mPreferenceMap.put(device, vPreference);
+ mDevicePreferenceCallback.onDeviceAdded(vPreference);
+ }
+ }
+
+ @Override
+ protected String getPreferenceKey() {
+ return PREF_KEY;
+ }
+
+ @Override
+ protected String getLogTag() {
+ return TAG;
+ }
+
+ @Override
+ protected void update(CachedBluetoothDevice cachedBluetoothDevice) {
+ super.update(cachedBluetoothDevice);
+ Log.d(TAG, "Map : " + mPreferenceMap);
+ }
+
+ @Override
+ protected void addPreference(
+ CachedBluetoothDevice cachedDevice, @BluetoothDevicePreference.SortType int type) {}
+
+ @Override
+ protected void launchDeviceDetails(Preference preference) {}
+
+ @Override
+ public void refreshPreference() {}
+
+ private void setDeviceVolume(CachedBluetoothDevice cachedDevice, int progress) {
+ if (mVolumeControl != null) {
+ mVolumeControl.setDeviceVolume(
+ cachedDevice.getDevice(), progress, /* isGroupOp= */ true);
+ }
+ }
+
+ private void setAudioManagerStreamVolume(int progress) {
+ int seekbarRange =
+ AudioSharingDeviceVolumePreference.MAX_VOLUME
+ - AudioSharingDeviceVolumePreference.MIN_VOLUME;
+ try {
+ AudioManager audioManager = mContext.getSystemService(AudioManager.class);
+ int streamVolumeRange =
+ audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+ - audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC);
+ int volume = Math.round((float) progress * streamVolumeRange / seekbarRange);
+ audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0);
+ } catch (RuntimeException e) {
+ Log.e(TAG, "Fail to setAudioManagerStreamVolumeForFallbackDevice, error = " + e);
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeGroupController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeGroupController.java
new file mode 100644
index 0000000..4a067ac
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeGroupController.java
@@ -0,0 +1,427 @@
+/*
+ * 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 static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID;
+
+import android.annotation.IntRange;
+import android.bluetooth.BluetoothCsipSetCoordinator;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothVolumeControl;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.media.AudioManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceGroup;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.bluetooth.BluetoothDeviceUpdater;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+
+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;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class AudioSharingDeviceVolumeGroupController extends AudioSharingBasePreferenceController
+ implements DevicePreferenceCallback {
+ private static final String TAG = "AudioSharingDeviceVolumeGroupController";
+ private static final String KEY = "audio_sharing_device_volume_group";
+
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final LocalBluetoothProfileManager mProfileManager;
+ @Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Nullable private final VolumeControlProfile mVolumeControl;
+ @Nullable private final ContentResolver mContentResolver;
+ @Nullable private BluetoothDeviceUpdater mBluetoothDeviceUpdater;
+ private final Executor mExecutor;
+ private final ContentObserver mSettingsObserver;
+ @Nullable private PreferenceGroup mPreferenceGroup;
+ private List<AudioSharingDeviceVolumePreference> mVolumePreferences = new ArrayList<>();
+ private Map<Integer, Integer> mValueMap = new HashMap<Integer, Integer>();
+ private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false);
+
+ private BluetoothVolumeControl.Callback mVolumeControlCallback =
+ new BluetoothVolumeControl.Callback() {
+ @Override
+ public void onVolumeOffsetChanged(
+ @NonNull BluetoothDevice device, int volumeOffset) {}
+
+ @Override
+ public void onDeviceVolumeChanged(
+ @NonNull BluetoothDevice device,
+ @IntRange(from = -255, to = 255) int volume) {
+ CachedBluetoothDevice cachedDevice =
+ mBtManager == null
+ ? null
+ : mBtManager.getCachedDeviceManager().findDevice(device);
+ if (cachedDevice == null) return;
+ int groupId = AudioSharingUtils.getGroupId(cachedDevice);
+ mValueMap.put(groupId, volume);
+ for (AudioSharingDeviceVolumePreference preference : mVolumePreferences) {
+ if (preference.getCachedDevice() != null
+ && AudioSharingUtils.getGroupId(preference.getCachedDevice())
+ == groupId) {
+ // If the callback return invalid volume, try to
+ // get the volume from AudioManager.STREAM_MUSIC
+ int finalVolume = getAudioVolumeIfNeeded(volume);
+ Log.d(
+ TAG,
+ "onDeviceVolumeChanged: set volume to "
+ + finalVolume
+ + " for "
+ + device.getAnonymizedAddress());
+ mContext.getMainExecutor()
+ .execute(() -> preference.setProgress(finalVolume));
+ break;
+ }
+ }
+ }
+ };
+
+ private BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
+ new BluetoothLeBroadcastAssistant.Callback() {
+ @Override
+ public void onSearchStarted(int reason) {}
+
+ @Override
+ public void onSearchStartFailed(int reason) {}
+
+ @Override
+ public void onSearchStopped(int reason) {}
+
+ @Override
+ public void onSearchStopFailed(int reason) {}
+
+ @Override
+ public void onSourceFound(@NonNull BluetoothLeBroadcastMetadata source) {}
+
+ @Override
+ public void onSourceAdded(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceAddFailed(
+ @NonNull BluetoothDevice sink,
+ @NonNull BluetoothLeBroadcastMetadata source,
+ int reason) {}
+
+ @Override
+ public void onSourceModified(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceModifyFailed(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceRemoved(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {
+ Log.d(TAG, "onSourceRemoved: update volume list.");
+ if (mBluetoothDeviceUpdater != null) {
+ mBluetoothDeviceUpdater.forceUpdate();
+ }
+ }
+
+ @Override
+ public void onSourceRemoveFailed(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onReceiveStateChanged(
+ @NonNull BluetoothDevice sink,
+ int sourceId,
+ @NonNull BluetoothLeBroadcastReceiveState state) {
+ if (BluetoothUtils.isConnected(state)) {
+ Log.d(TAG, "onReceiveStateChanged: synced, update volume list.");
+ if (mBluetoothDeviceUpdater != null) {
+ mBluetoothDeviceUpdater.forceUpdate();
+ }
+ }
+ }
+ };
+
+ public AudioSharingDeviceVolumeGroupController(Context context) {
+ super(context, KEY);
+ mBtManager = Utils.getLocalBtManager(mContext);
+ mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
+ mAssistant =
+ mProfileManager == null
+ ? null
+ : mProfileManager.getLeAudioBroadcastAssistantProfile();
+ mVolumeControl = mProfileManager == null ? null : mProfileManager.getVolumeControlProfile();
+ mExecutor = Executors.newSingleThreadExecutor();
+ mContentResolver = context.getContentResolver();
+ mSettingsObserver = new SettingsObserver();
+ }
+
+ private class SettingsObserver extends ContentObserver {
+ SettingsObserver() {
+ super(new Handler(Looper.getMainLooper()));
+ }
+
+ @Override
+ public void onChange(boolean selfChange) {
+ Log.d(TAG, "onChange, fallback device group id has been changed");
+ for (AudioSharingDeviceVolumePreference preference : mVolumePreferences) {
+ preference.setOrder(getPreferenceOrderForDevice(preference.getCachedDevice()));
+ }
+ }
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ super.onStart(owner);
+ registerCallbacks();
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ super.onStop(owner);
+ unregisterCallbacks();
+ }
+
+ @Override
+ public void onDestroy(@NonNull LifecycleOwner owner) {
+ mVolumePreferences.clear();
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+
+ mPreferenceGroup = screen.findPreference(KEY);
+ if (mPreferenceGroup != null) {
+ mPreferenceGroup.setVisible(false);
+ }
+
+ if (isAvailable() && mBluetoothDeviceUpdater != null) {
+ mBluetoothDeviceUpdater.setPrefContext(screen.getContext());
+ mBluetoothDeviceUpdater.forceUpdate();
+ }
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return KEY;
+ }
+
+ @Override
+ public void onDeviceAdded(Preference preference) {
+ if (mPreferenceGroup != null) {
+ if (mPreferenceGroup.getPreferenceCount() == 0) {
+ mPreferenceGroup.setVisible(true);
+ }
+ mPreferenceGroup.addPreference(preference);
+ }
+ if (preference instanceof AudioSharingDeviceVolumePreference) {
+ var volumePref = (AudioSharingDeviceVolumePreference) preference;
+ CachedBluetoothDevice cachedDevice = volumePref.getCachedDevice();
+ volumePref.setOrder(getPreferenceOrderForDevice(cachedDevice));
+ mVolumePreferences.add(volumePref);
+ if (volumePref.getProgress() > 0) return;
+ int volume = mValueMap.getOrDefault(AudioSharingUtils.getGroupId(cachedDevice), -1);
+ // If the volume is invalid, try to get the volume from AudioManager.STREAM_MUSIC
+ int finalVolume = getAudioVolumeIfNeeded(volume);
+ Log.d(
+ TAG,
+ "onDeviceAdded: set volume to "
+ + finalVolume
+ + " for "
+ + cachedDevice.getDevice().getAnonymizedAddress());
+ AudioSharingUtils.postOnMainThread(mContext, () -> volumePref.setProgress(finalVolume));
+ }
+ }
+
+ @Override
+ public void onDeviceRemoved(Preference preference) {
+ if (mPreferenceGroup != null) {
+ mPreferenceGroup.removePreference(preference);
+ if (mPreferenceGroup.getPreferenceCount() == 0) {
+ mPreferenceGroup.setVisible(false);
+ }
+ }
+ if (preference instanceof AudioSharingDeviceVolumePreference) {
+ var volumePref = (AudioSharingDeviceVolumePreference) preference;
+ if (mVolumePreferences.contains(volumePref)) {
+ mVolumePreferences.remove(volumePref);
+ }
+ CachedBluetoothDevice device = volumePref.getCachedDevice();
+ Log.d(
+ TAG,
+ "onDeviceRemoved: "
+ + (device == null
+ ? "null"
+ : device.getDevice().getAnonymizedAddress()));
+ }
+ }
+
+ @Override
+ public void updateVisibility() {
+ if (mPreferenceGroup != null && mPreferenceGroup.getPreferenceCount() == 0) {
+ mPreferenceGroup.setVisible(false);
+ return;
+ }
+ super.updateVisibility();
+ }
+
+ @Override
+ public void onAudioSharingProfilesConnected() {
+ registerCallbacks();
+ }
+
+ /**
+ * Initialize the controller.
+ *
+ * @param fragment The fragment to provide the context and metrics category for {@link
+ * AudioSharingBluetoothDeviceUpdater} and provide the host for dialogs.
+ */
+ public void init(DashboardFragment fragment) {
+ mBluetoothDeviceUpdater =
+ new AudioSharingDeviceVolumeControlUpdater(
+ fragment.getContext(),
+ AudioSharingDeviceVolumeGroupController.this,
+ fragment.getMetricsCategory());
+ }
+
+ @VisibleForTesting
+ public void setDeviceUpdater(@Nullable AudioSharingDeviceVolumeControlUpdater updater) {
+ mBluetoothDeviceUpdater = updater;
+ }
+
+ /** Test only: set callback registration status in tests. */
+ @VisibleForTesting
+ public void setCallbacksRegistered(boolean registered) {
+ mCallbacksRegistered.set(registered);
+ }
+
+ /** Test only: set volume map in tests. */
+ @VisibleForTesting
+ public void setVolumeMap(@Nullable Map<Integer, Integer> map) {
+ mValueMap.clear();
+ mValueMap.putAll(map);
+ }
+
+ /** Test only: set value for private preferenceGroup in tests. */
+ @VisibleForTesting
+ public void setPreferenceGroup(@Nullable PreferenceGroup group) {
+ mPreferenceGroup = group;
+ mPreference = group;
+ }
+
+ @VisibleForTesting
+ ContentObserver getSettingsObserver() {
+ return mSettingsObserver;
+ }
+
+ private void registerCallbacks() {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip registerCallbacks(). Feature is not available.");
+ return;
+ }
+ if (mAssistant == null
+ || mVolumeControl == null
+ || mBluetoothDeviceUpdater == null
+ || mContentResolver == null
+ || !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ Log.d(TAG, "Skip registerCallbacks(). Profile is not ready.");
+ return;
+ }
+ if (!mCallbacksRegistered.get()) {
+ Log.d(TAG, "registerCallbacks()");
+ mAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
+ mVolumeControl.registerCallback(mExecutor, mVolumeControlCallback);
+ mBluetoothDeviceUpdater.registerCallback();
+ mContentResolver.registerContentObserver(
+ Settings.Secure.getUriFor(SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID),
+ false,
+ mSettingsObserver);
+ mCallbacksRegistered.set(true);
+ }
+ }
+
+ private void unregisterCallbacks() {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip unregister callbacks. Feature is not available.");
+ return;
+ }
+ if (mAssistant == null
+ || mVolumeControl == null
+ || mBluetoothDeviceUpdater == null
+ || mContentResolver == null
+ || !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ Log.d(TAG, "Skip unregisterCallbacks(). Profile is not ready.");
+ return;
+ }
+ if (mCallbacksRegistered.get()) {
+ Log.d(TAG, "unregisterCallbacks()");
+ mAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
+ mVolumeControl.unregisterCallback(mVolumeControlCallback);
+ mBluetoothDeviceUpdater.unregisterCallback();
+ mContentResolver.unregisterContentObserver(mSettingsObserver);
+ mValueMap.clear();
+ mCallbacksRegistered.set(false);
+ }
+ }
+
+ private int getAudioVolumeIfNeeded(int volume) {
+ if (volume >= 0) return volume;
+ try {
+ AudioManager audioManager = mContext.getSystemService(AudioManager.class);
+ int max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
+ int min = audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC);
+ return Math.round(
+ audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) * 255f / (max - min));
+ } catch (RuntimeException e) {
+ Log.e(TAG, "Fail to fetch current music stream volume, error = " + e);
+ return volume;
+ }
+ }
+
+ private int getPreferenceOrderForDevice(@NonNull CachedBluetoothDevice cachedDevice) {
+ int groupId = AudioSharingUtils.getGroupId(cachedDevice);
+ // The fallback device rank first among the audio sharing device list.
+ return (groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID
+ && groupId == AudioSharingUtils.getFallbackActiveGroupId(mContext))
+ ? 0
+ : 1;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumePreference.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumePreference.java
new file mode 100644
index 0000000..01afc02
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumePreference.java
@@ -0,0 +1,57 @@
+/*
+ * 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.content.Context;
+import android.widget.SeekBar;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.settings.R;
+import com.android.settings.widget.SeekBarPreference;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+
+public class AudioSharingDeviceVolumePreference extends SeekBarPreference {
+ public static final int MIN_VOLUME = 0;
+ public static final int MAX_VOLUME = 255;
+
+ private final CachedBluetoothDevice mCachedDevice;
+ @Nullable protected SeekBar mSeekBar;
+
+ public AudioSharingDeviceVolumePreference(
+ Context context, @NonNull CachedBluetoothDevice device) {
+ super(context);
+ setLayoutResource(R.layout.preference_volume_slider);
+ mCachedDevice = device;
+ }
+
+ @NonNull
+ public CachedBluetoothDevice getCachedDevice() {
+ return mCachedDevice;
+ }
+
+ /**
+ * Initialize {@link AudioSharingDeviceVolumePreference}.
+ *
+ * <p>Need to be called after creating the preference.
+ */
+ public void initialize() {
+ setMax(MAX_VOLUME);
+ setMin(MIN_VOLUME);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFactory.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFactory.java
new file mode 100644
index 0000000..165beae
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFactory.java
@@ -0,0 +1,348 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+import androidx.appcompat.app.AlertDialog;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.settings.R;
+
+import javax.annotation.CheckReturnValue;
+
+public class AudioSharingDialogFactory {
+ private static final String TAG = "AudioSharingDialogFactory";
+
+ /**
+ * Initializes a builder for the dialog to be shown for audio sharing.
+ *
+ * @param context The {@link Context} that will be used to create the dialog.
+ * @return A configurable builder for the dialog.
+ */
+ @NonNull
+ public static AudioSharingDialogFactory.DialogBuilder newBuilder(@NonNull Context context) {
+ return new AudioSharingDialogFactory.DialogBuilder(context);
+ }
+
+ /** Builder class with configurable options for the dialog to be shown for audio sharing. */
+ public static class DialogBuilder {
+ private Context mContext;
+ private AlertDialog.Builder mBuilder;
+ private View mCustomTitle;
+ private View mCustomBody;
+ private boolean mIsCustomBodyEnabled;
+
+ /**
+ * Private constructor for the dialog builder class. Should not be invoked directly;
+ * instead, use {@link AudioSharingDialogFactory#newBuilder(Context)}.
+ *
+ * @param context The {@link Context} that will be used to create the dialog.
+ */
+ private DialogBuilder(@NonNull Context context) {
+ mContext = context;
+ mBuilder = new AlertDialog.Builder(context);
+ LayoutInflater inflater = LayoutInflater.from(mBuilder.getContext());
+ mCustomTitle =
+ inflater.inflate(R.layout.dialog_custom_title_audio_sharing, /* root= */ null);
+ mCustomBody =
+ inflater.inflate(R.layout.dialog_custom_body_audio_sharing, /* parent= */ null);
+ }
+
+ /**
+ * Sets title of the dialog custom title.
+ *
+ * @param titleRes Resource ID of the string to be used for the dialog title.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setTitle(@StringRes int titleRes) {
+ TextView title = mCustomTitle.findViewById(R.id.title_text);
+ title.setText(titleRes);
+ return this;
+ }
+
+ /**
+ * Sets title of the dialog custom title.
+ *
+ * @param titleText The text to be used for the title.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setTitle(@NonNull CharSequence titleText) {
+ TextView title = mCustomTitle.findViewById(R.id.title_text);
+ title.setText(titleText);
+ return this;
+ }
+
+ /**
+ * Sets the title icon of the dialog custom title.
+ *
+ * @param iconRes The text to be used for the title.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setTitleIcon(@DrawableRes int iconRes) {
+ ImageView icon = mCustomTitle.findViewById(R.id.title_icon);
+ icon.setImageResource(iconRes);
+ return this;
+ }
+
+ /**
+ * Sets the message body of the dialog.
+ *
+ * @param messageRes Resource ID of the string to be used for the message body.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setMessage(@StringRes int messageRes) {
+ mBuilder.setMessage(messageRes);
+ return this;
+ }
+
+ /**
+ * Sets the message body of the dialog.
+ *
+ * @param message The text to be used for the message body.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setMessage(@NonNull CharSequence message) {
+ mBuilder.setMessage(message);
+ return this;
+ }
+
+ /** Whether to use custom body. */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setIsCustomBodyEnabled(
+ boolean isCustomBodyEnabled) {
+ mIsCustomBodyEnabled = isCustomBodyEnabled;
+ return this;
+ }
+
+ /**
+ * Sets the custom image of the dialog custom body.
+ *
+ * @param iconRes The text to be used for the title.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setCustomImage(@DrawableRes int iconRes) {
+ ImageView image = mCustomBody.findViewById(R.id.description_image);
+ image.setImageResource(iconRes);
+ image.setVisibility(View.VISIBLE);
+ return this;
+ }
+
+ /**
+ * Sets the custom message of the dialog custom body.
+ *
+ * @param messageRes Resource ID of the string to be used for the message body.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setCustomMessage(@StringRes int messageRes) {
+ TextView subTitle = mCustomBody.findViewById(R.id.description_text);
+ subTitle.setText(messageRes);
+ subTitle.setVisibility(View.VISIBLE);
+ return this;
+ }
+
+ /**
+ * Sets the custom message of the dialog custom body.
+ *
+ * @param message The text to be used for the custom message body.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setCustomMessage(
+ @NonNull CharSequence message) {
+ TextView subTitle = mCustomBody.findViewById(R.id.description_text);
+ subTitle.setText(message);
+ subTitle.setVisibility(View.VISIBLE);
+ return this;
+ }
+
+ /**
+ * Sets the custom device actions of the dialog custom body.
+ *
+ * @param adapter The adapter for device items to build dialog actions.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setCustomDeviceActions(
+ @NonNull AudioSharingDeviceAdapter adapter) {
+ RecyclerView recyclerView = mCustomBody.findViewById(R.id.device_btn_list);
+ recyclerView.setAdapter(adapter);
+ recyclerView.setLayoutManager(
+ new LinearLayoutManager(mContext, LinearLayoutManager.VERTICAL, false));
+ recyclerView.setVisibility(View.VISIBLE);
+ return this;
+ }
+
+ /**
+ * Sets the positive button label and listener for the dialog.
+ *
+ * @param labelRes Resource ID of the string to be used for the positive button label.
+ * @param listener The listener to be invoked when the positive button is pressed.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setPositiveButton(
+ @StringRes int labelRes, @NonNull DialogInterface.OnClickListener listener) {
+ mBuilder.setPositiveButton(labelRes, listener);
+ return this;
+ }
+
+ /**
+ * Sets the positive button label and listener for the dialog.
+ *
+ * @param label The text to be used for the positive button label.
+ * @param listener The listener to be invoked when the positive button is pressed.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setPositiveButton(
+ @NonNull CharSequence label, @NonNull DialogInterface.OnClickListener listener) {
+ mBuilder.setPositiveButton(label, listener);
+ return this;
+ }
+
+ /**
+ * Sets the custom positive button label and listener for the dialog custom body.
+ *
+ * @param labelRes Resource ID of the string to be used for the positive button label.
+ * @param listener The listener to be invoked when the positive button is pressed.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setCustomPositiveButton(
+ @StringRes int labelRes, @NonNull View.OnClickListener listener) {
+ Button positiveBtn = mCustomBody.findViewById(R.id.positive_btn);
+ positiveBtn.setText(labelRes);
+ positiveBtn.setOnClickListener(listener);
+ positiveBtn.setVisibility(View.VISIBLE);
+ return this;
+ }
+
+ /**
+ * Sets the custom positive button label and listener for the dialog custom body.
+ *
+ * @param label The text to be used for the positive button label.
+ * @param listener The listener to be invoked when the positive button is pressed.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setCustomPositiveButton(
+ @NonNull CharSequence label, @NonNull View.OnClickListener listener) {
+ Button positiveBtn = mCustomBody.findViewById(R.id.positive_btn);
+ positiveBtn.setText(label);
+ positiveBtn.setOnClickListener(listener);
+ positiveBtn.setVisibility(View.VISIBLE);
+ return this;
+ }
+
+ /**
+ * Sets the negative button label and listener for the dialog.
+ *
+ * @param labelRes Resource ID of the string to be used for the negative button label.
+ * @param listener The listener to be invoked when the negative button is pressed.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setNegativeButton(
+ @StringRes int labelRes, @NonNull DialogInterface.OnClickListener listener) {
+ mBuilder.setNegativeButton(labelRes, listener);
+ return this;
+ }
+
+ /**
+ * Sets the negative button label and listener for the dialog.
+ *
+ * @param label The text to be used for the negative button label.
+ * @param listener The listener to be invoked when the negative button is pressed.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setNegativeButton(
+ @NonNull CharSequence label, @NonNull DialogInterface.OnClickListener listener) {
+ mBuilder.setNegativeButton(label, listener);
+ return this;
+ }
+
+ /**
+ * Sets the custom negative button label and listener for the dialog custom body.
+ *
+ * @param labelRes Resource ID of the string to be used for the negative button label.
+ * @param listener The listener to be invoked when the negative button is pressed.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setCustomNegativeButton(
+ @StringRes int labelRes, @NonNull View.OnClickListener listener) {
+ Button negativeBtn = mCustomBody.findViewById(R.id.negative_btn);
+ negativeBtn.setText(labelRes);
+ negativeBtn.setOnClickListener(listener);
+ negativeBtn.setVisibility(View.VISIBLE);
+ return this;
+ }
+
+ /**
+ * Sets the custom negative button label and listener for the dialog custom body.
+ *
+ * @param label The text to be used for the negative button label.
+ * @param listener The listener to be invoked when the negative button is pressed.
+ * @return This builder.
+ */
+ @NonNull
+ public AudioSharingDialogFactory.DialogBuilder setCustomNegativeButton(
+ @NonNull CharSequence label, @NonNull View.OnClickListener listener) {
+ Button negativeBtn = mCustomBody.findViewById(R.id.negative_btn);
+ negativeBtn.setText(label);
+ negativeBtn.setOnClickListener(listener);
+ negativeBtn.setVisibility(View.VISIBLE);
+ return this;
+ }
+
+ /**
+ * Builds a dialog with the current configs.
+ *
+ * @return The dialog to be shown for audio sharing.
+ */
+ @NonNull
+ @CheckReturnValue
+ public AlertDialog build() {
+ if (mIsCustomBodyEnabled) {
+ mBuilder.setView(mCustomBody);
+ }
+ final AlertDialog dialog =
+ mBuilder.setCustomTitle(mCustomTitle).setCancelable(false).create();
+ dialog.setCanceledOnTouchOutside(false);
+ return dialog;
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java
new file mode 100644
index 0000000..6f7de8c
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java
@@ -0,0 +1,137 @@
+/*
+ * 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.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.android.settings.R;
+import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
+
+import com.google.common.collect.Iterables;
+
+import java.util.List;
+
+public class AudioSharingDialogFragment extends InstrumentedDialogFragment {
+ private static final String TAG = "AudioSharingDialog";
+
+ private static final String BUNDLE_KEY_DEVICE_ITEMS = "bundle_key_device_items";
+
+ // 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 for sharing in the dialog.
+ *
+ * @param item The device item clicked.
+ */
+ void onItemClick(AudioSharingDeviceItem item);
+ }
+
+ @Nullable private static DialogEventListener sListener;
+
+ @Override
+ public int getMetricsCategory() {
+ return SettingsEnums.DIALOG_START_AUDIO_SHARING;
+ }
+
+ /**
+ * Display the {@link AudioSharingDialogFragment} dialog.
+ *
+ * @param host The Fragment this dialog will be hosted.
+ * @param deviceItems The connected device items eligible for audio sharing.
+ * @param listener The callback to handle the user action on this dialog.
+ */
+ public static void show(
+ @NonNull Fragment host,
+ @NonNull List<AudioSharingDeviceItem> deviceItems,
+ @NonNull DialogEventListener listener) {
+ if (!AudioSharingUtils.isFeatureEnabled()) return;
+ final FragmentManager manager = host.getChildFragmentManager();
+ sListener = listener;
+ AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG);
+ if (dialog != null) {
+ Log.d(TAG, "Dialog is showing, return.");
+ return;
+ }
+ Log.d(TAG, "Show up the dialog.");
+ final Bundle bundle = new Bundle();
+ bundle.putParcelableList(BUNDLE_KEY_DEVICE_ITEMS, deviceItems);
+ AudioSharingDialogFragment dialogFrag = new AudioSharingDialogFragment();
+ dialogFrag.setArguments(bundle);
+ dialogFrag.show(manager, TAG);
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ Bundle arguments = requireArguments();
+ List<AudioSharingDeviceItem> deviceItems =
+ arguments.getParcelable(BUNDLE_KEY_DEVICE_ITEMS, List.class);
+ AudioSharingDialogFactory.DialogBuilder builder =
+ AudioSharingDialogFactory.newBuilder(getActivity())
+ .setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
+ .setIsCustomBodyEnabled(true);
+ if (deviceItems.isEmpty()) {
+ builder.setTitle(R.string.audio_sharing_share_dialog_title)
+ .setCustomImage(R.drawable.audio_sharing_guidance)
+ .setCustomMessage(R.string.audio_sharing_dialog_connect_device_content)
+ .setNegativeButton(
+ R.string.audio_sharing_close_button_label, (dig, which) -> dismiss());
+ } else if (deviceItems.size() == 1) {
+ AudioSharingDeviceItem deviceItem = Iterables.getOnlyElement(deviceItems);
+ builder.setTitle(
+ getString(
+ R.string.audio_sharing_share_with_dialog_title,
+ deviceItem.getName()))
+ .setCustomMessage(R.string.audio_sharing_dialog_share_content)
+ .setCustomPositiveButton(
+ R.string.audio_sharing_share_button_label,
+ v -> {
+ if (sListener != null) {
+ sListener.onItemClick(deviceItem);
+ }
+ dismiss();
+ })
+ .setCustomNegativeButton(
+ R.string.audio_sharing_no_thanks_button_label, v -> dismiss());
+ } else {
+ builder.setTitle(R.string.audio_sharing_share_with_more_dialog_title)
+ .setCustomMessage(R.string.audio_sharing_dialog_share_more_content)
+ .setCustomDeviceActions(
+ new AudioSharingDeviceAdapter(
+ getContext(),
+ deviceItems,
+ (AudioSharingDeviceItem item) -> {
+ if (sListener != null) {
+ sListener.onItemClick(item);
+ }
+ dismiss();
+ },
+ AudioSharingDeviceAdapter.ActionType.SHARE))
+ .setCustomNegativeButton(com.android.settings.R.string.cancel, v -> dismiss());
+ }
+ return builder.build();
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandler.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandler.java
new file mode 100644
index 0000000..c329e82
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandler.java
@@ -0,0 +1,452 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import android.app.settings.SettingsEnums;
+import android.bluetooth.BluetoothCsipSetCoordinator;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcast;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.SubSettingLauncher;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Executor;
+
+public class AudioSharingDialogHandler {
+ private static final String TAG = "AudioSharingDialogHandler";
+ private final Context mContext;
+ private final Fragment mHostFragment;
+ @Nullable private final LocalBluetoothManager mLocalBtManager;
+ @Nullable private final LocalBluetoothLeBroadcast mBroadcast;
+ @Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
+ private List<BluetoothDevice> mTargetSinks = new ArrayList<>();
+
+ private final BluetoothLeBroadcast.Callback mBroadcastCallback =
+ new BluetoothLeBroadcast.Callback() {
+ @Override
+ public void onBroadcastStarted(int reason, int broadcastId) {
+ Log.d(
+ TAG,
+ "onBroadcastStarted(), reason = "
+ + reason
+ + ", broadcastId = "
+ + broadcastId);
+ }
+
+ @Override
+ public void onBroadcastStartFailed(int reason) {
+ Log.d(TAG, "onBroadcastStartFailed(), reason = " + reason);
+ AudioSharingUtils.toastMessage(
+ mContext, "Fail to start broadcast, reason " + reason);
+ }
+
+ @Override
+ public void onBroadcastMetadataChanged(
+ int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) {
+ Log.d(
+ TAG,
+ "onBroadcastMetadataChanged(), broadcastId = "
+ + broadcastId
+ + ", metadata = "
+ + metadata);
+ }
+
+ @Override
+ public void onBroadcastStopped(int reason, int broadcastId) {
+ Log.d(
+ TAG,
+ "onBroadcastStopped(), reason = "
+ + reason
+ + ", broadcastId = "
+ + broadcastId);
+ }
+
+ @Override
+ public void onBroadcastStopFailed(int reason) {
+ Log.d(TAG, "onBroadcastStopFailed(), reason = " + reason);
+ AudioSharingUtils.toastMessage(
+ mContext, "Fail to stop broadcast, reason " + 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) {
+ Log.d(
+ TAG,
+ "onPlaybackStarted(), reason = "
+ + reason
+ + ", broadcastId = "
+ + broadcastId);
+ if (!mTargetSinks.isEmpty()) {
+ AudioSharingUtils.addSourceToTargetSinks(mTargetSinks, mLocalBtManager);
+ new SubSettingLauncher(mContext)
+ .setDestination(AudioSharingDashboardFragment.class.getName())
+ .setSourceMetricsCategory(
+ (mHostFragment != null
+ && mHostFragment
+ instanceof DashboardFragment)
+ ? ((DashboardFragment) mHostFragment)
+ .getMetricsCategory()
+ : SettingsEnums.PAGE_UNKNOWN)
+ .launch();
+ mTargetSinks = new ArrayList<>();
+ }
+ }
+
+ @Override
+ public void onPlaybackStopped(int reason, int broadcastId) {}
+ };
+
+ public AudioSharingDialogHandler(@NonNull Context context, @NonNull Fragment fragment) {
+ mContext = context;
+ mHostFragment = fragment;
+ mLocalBtManager = Utils.getLocalBluetoothManager(context);
+ mBroadcast =
+ mLocalBtManager != null
+ ? mLocalBtManager.getProfileManager().getLeAudioBroadcastProfile()
+ : null;
+ mAssistant =
+ mLocalBtManager != null
+ ? mLocalBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile()
+ : null;
+ }
+
+ /** Register callbacks for dialog handler */
+ public void registerCallbacks(Executor executor) {
+ if (mBroadcast != null) {
+ mBroadcast.registerServiceCallBack(executor, mBroadcastCallback);
+ }
+ }
+
+ /** Unregister callbacks for dialog handler */
+ public void unregisterCallbacks() {
+ if (mBroadcast != null) {
+ mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
+ }
+ }
+
+ /** Handle dialog pop-up logic when device is connected. */
+ public void handleDeviceConnected(
+ @NonNull CachedBluetoothDevice cachedDevice, boolean userTriggered) {
+ String anonymizedAddress = cachedDevice.getDevice().getAnonymizedAddress();
+ boolean isBroadcasting = isBroadcasting();
+ boolean isLeAudioSupported = AudioSharingUtils.isLeAudioSupported(cachedDevice);
+ if (!isLeAudioSupported) {
+ Log.d(TAG, "Handle non LE audio device connected, device = " + anonymizedAddress);
+ // Handle connected ineligible (non LE audio) remote device
+ handleNonLeAudioDeviceConnected(cachedDevice, isBroadcasting, userTriggered);
+ } else {
+ Log.d(TAG, "Handle LE audio device connected, device = " + anonymizedAddress);
+ // Handle connected eligible (LE audio) remote device
+ handleLeAudioDeviceConnected(cachedDevice, isBroadcasting, userTriggered);
+ }
+ }
+
+ private void handleNonLeAudioDeviceConnected(
+ @NonNull CachedBluetoothDevice cachedDevice,
+ boolean isBroadcasting,
+ boolean userTriggered) {
+ if (isBroadcasting) {
+ // Show stop audio sharing dialog when an ineligible (non LE audio) remote device
+ // connected during a sharing session.
+ Map<Integer, List<CachedBluetoothDevice>> groupedDevices =
+ AudioSharingUtils.fetchConnectedDevicesByGroupId(mLocalBtManager);
+ List<AudioSharingDeviceItem> deviceItemsInSharingSession =
+ AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem(
+ mLocalBtManager, groupedDevices, /* filterByInSharing= */ true);
+ postOnMainThread(
+ () -> {
+ closeOpeningDialogsOtherThan(AudioSharingStopDialogFragment.tag());
+ AudioSharingStopDialogFragment.show(
+ mHostFragment,
+ deviceItemsInSharingSession,
+ cachedDevice,
+ () -> {
+ cachedDevice.setActive();
+ AudioSharingUtils.stopBroadcasting(mLocalBtManager);
+ });
+ });
+ } else {
+ if (userTriggered) {
+ cachedDevice.setActive();
+ }
+ // Do nothing for ineligible (non LE audio) remote device when no sharing session.
+ Log.d(
+ TAG,
+ "Ignore onProfileConnectionStateChanged for non LE audio without"
+ + " sharing session");
+ }
+ }
+
+ private void handleLeAudioDeviceConnected(
+ @NonNull CachedBluetoothDevice cachedDevice,
+ boolean isBroadcasting,
+ boolean userTriggered) {
+ Map<Integer, List<CachedBluetoothDevice>> groupedDevices =
+ AudioSharingUtils.fetchConnectedDevicesByGroupId(mLocalBtManager);
+ if (isBroadcasting) {
+ // If another device within the same is already in the sharing session, add source to
+ // the device automatically.
+ int groupId = AudioSharingUtils.getGroupId(cachedDevice);
+ if (groupedDevices.containsKey(groupId)
+ && groupedDevices.get(groupId).stream()
+ .anyMatch(
+ device ->
+ BluetoothUtils.hasConnectedBroadcastSource(
+ device, mLocalBtManager))) {
+ Log.d(
+ TAG,
+ "Automatically add another device within the same group to the sharing: "
+ + cachedDevice.getDevice().getAnonymizedAddress());
+ if (mAssistant != null && mBroadcast != null) {
+ mAssistant.addSource(
+ cachedDevice.getDevice(),
+ mBroadcast.getLatestBluetoothLeBroadcastMetadata(),
+ /* isGroupOp= */ false);
+ }
+ return;
+ }
+
+ // Show audio sharing switch or join dialog according to device count in the sharing
+ // session.
+ List<AudioSharingDeviceItem> deviceItemsInSharingSession =
+ AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem(
+ mLocalBtManager, groupedDevices, /* filterByInSharing= */ true);
+ // Show audio sharing switch dialog when the third eligible (LE audio) remote device
+ // connected during a sharing session.
+ if (deviceItemsInSharingSession.size() >= 2) {
+ postOnMainThread(
+ () -> {
+ closeOpeningDialogsOtherThan(
+ AudioSharingDisconnectDialogFragment.tag());
+ AudioSharingDisconnectDialogFragment.show(
+ mHostFragment,
+ deviceItemsInSharingSession,
+ cachedDevice,
+ (AudioSharingDeviceItem item) -> {
+ // Remove all sources from the device user clicked
+ removeSourceForGroup(item.getGroupId(), groupedDevices);
+ // Add current broadcast to the latest connected device
+ addSourceForGroup(groupId, groupedDevices);
+ });
+ });
+ } else {
+ // Show audio sharing join dialog when the first or second eligible (LE audio)
+ // remote device connected during a sharing session.
+ postOnMainThread(
+ () -> {
+ closeOpeningDialogsOtherThan(AudioSharingJoinDialogFragment.tag());
+ AudioSharingJoinDialogFragment.show(
+ mHostFragment,
+ deviceItemsInSharingSession,
+ cachedDevice,
+ new AudioSharingJoinDialogFragment.DialogEventListener() {
+ @Override
+ public void onShareClick() {
+ addSourceForGroup(groupId, groupedDevices);
+ }
+
+ @Override
+ public void onCancelClick() {}
+ });
+ });
+ }
+ } else {
+ List<AudioSharingDeviceItem> deviceItems = new ArrayList<>();
+ for (List<CachedBluetoothDevice> devices : groupedDevices.values()) {
+ // Use random device in the group within the sharing session to represent the group.
+ CachedBluetoothDevice device = devices.get(0);
+ if (AudioSharingUtils.getGroupId(device)
+ == AudioSharingUtils.getGroupId(cachedDevice)) {
+ continue;
+ }
+ deviceItems.add(AudioSharingUtils.buildAudioSharingDeviceItem(device));
+ }
+ // Show audio sharing join dialog when the second eligible (LE audio) remote
+ // device connect and no sharing session.
+ if (deviceItems.size() == 1) {
+ postOnMainThread(
+ () -> {
+ closeOpeningDialogsOtherThan(AudioSharingJoinDialogFragment.tag());
+ AudioSharingJoinDialogFragment.show(
+ mHostFragment,
+ deviceItems,
+ cachedDevice,
+ new AudioSharingJoinDialogFragment.DialogEventListener() {
+ @Override
+ public void onShareClick() {
+ mTargetSinks = new ArrayList<>();
+ for (List<CachedBluetoothDevice> devices :
+ groupedDevices.values()) {
+ for (CachedBluetoothDevice device : devices) {
+ mTargetSinks.add(device.getDevice());
+ }
+ }
+ Log.d(
+ TAG,
+ "Start broadcast with sinks: "
+ + mTargetSinks.size());
+ if (mBroadcast != null) {
+ mBroadcast.startPrivateBroadcast();
+ }
+ }
+
+ @Override
+ public void onCancelClick() {
+ if (userTriggered) {
+ cachedDevice.setActive();
+ }
+ }
+ });
+ });
+ } else if (userTriggered) {
+ cachedDevice.setActive();
+ }
+ }
+ }
+
+ private void closeOpeningDialogsOtherThan(String tag) {
+ if (mHostFragment == null) return;
+ List<Fragment> fragments = mHostFragment.getChildFragmentManager().getFragments();
+ for (Fragment fragment : fragments) {
+ if (fragment instanceof DialogFragment && !fragment.getTag().equals(tag)) {
+ Log.d(TAG, "Remove staled opening dialog " + fragment.getTag());
+ ((DialogFragment) fragment).dismiss();
+ }
+ }
+ }
+
+ /** Close opening dialogs for le audio device */
+ public void closeOpeningDialogsForLeaDevice(@NonNull CachedBluetoothDevice cachedDevice) {
+ if (mHostFragment == null) return;
+ int groupId = AudioSharingUtils.getGroupId(cachedDevice);
+ List<Fragment> fragments = mHostFragment.getChildFragmentManager().getFragments();
+ for (Fragment fragment : fragments) {
+ CachedBluetoothDevice device = getCachedBluetoothDeviceFromDialog(fragment);
+ if (device != null
+ && groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID
+ && AudioSharingUtils.getGroupId(device) == groupId) {
+ Log.d(TAG, "Remove staled opening dialog for group " + groupId);
+ ((DialogFragment) fragment).dismiss();
+ }
+ }
+ }
+
+ /** Close opening dialogs for non le audio device */
+ public void closeOpeningDialogsForNonLeaDevice(@NonNull CachedBluetoothDevice cachedDevice) {
+ if (mHostFragment == null) return;
+ String address = cachedDevice.getAddress();
+ List<Fragment> fragments = mHostFragment.getChildFragmentManager().getFragments();
+ for (Fragment fragment : fragments) {
+ CachedBluetoothDevice device = getCachedBluetoothDeviceFromDialog(fragment);
+ if (device != null && address != null && address.equals(device.getAddress())) {
+ Log.d(
+ TAG,
+ "Remove staled opening dialog for device "
+ + cachedDevice.getDevice().getAnonymizedAddress());
+ ((DialogFragment) fragment).dismiss();
+ }
+ }
+ }
+
+ @Nullable
+ private CachedBluetoothDevice getCachedBluetoothDeviceFromDialog(Fragment fragment) {
+ CachedBluetoothDevice device = null;
+ if (fragment instanceof AudioSharingJoinDialogFragment) {
+ device = ((AudioSharingJoinDialogFragment) fragment).getDevice();
+ } else if (fragment instanceof AudioSharingStopDialogFragment) {
+ device = ((AudioSharingStopDialogFragment) fragment).getDevice();
+ } else if (fragment instanceof AudioSharingDisconnectDialogFragment) {
+ device = ((AudioSharingDisconnectDialogFragment) fragment).getDevice();
+ }
+ return device;
+ }
+
+ private void removeSourceForGroup(
+ int groupId, Map<Integer, List<CachedBluetoothDevice>> groupedDevices) {
+ if (mAssistant == null) {
+ Log.d(TAG, "Fail to add source due to null profiles, group = " + groupId);
+ return;
+ }
+ if (!groupedDevices.containsKey(groupId)) {
+ Log.d(TAG, "Fail to remove source for group " + groupId);
+ return;
+ }
+ groupedDevices.get(groupId).stream()
+ .map(CachedBluetoothDevice::getDevice)
+ .filter(device -> device != null)
+ .forEach(
+ device -> {
+ for (BluetoothLeBroadcastReceiveState source :
+ mAssistant.getAllSources(device)) {
+ mAssistant.removeSource(device, source.getSourceId());
+ }
+ });
+ }
+
+ private void addSourceForGroup(
+ int groupId, Map<Integer, List<CachedBluetoothDevice>> groupedDevices) {
+ if (mBroadcast == null || mAssistant == null) {
+ Log.d(TAG, "Fail to add source due to null profiles, group = " + groupId);
+ return;
+ }
+ if (!groupedDevices.containsKey(groupId)) {
+ Log.d(TAG, "Fail to add source due to invalid group id, group = " + groupId);
+ return;
+ }
+ groupedDevices.get(groupId).stream()
+ .map(CachedBluetoothDevice::getDevice)
+ .filter(device -> device != null)
+ .forEach(
+ device ->
+ mAssistant.addSource(
+ device,
+ mBroadcast.getLatestBluetoothLeBroadcastMetadata(),
+ /* isGroupOp= */ false));
+ }
+
+ private void postOnMainThread(@NonNull Runnable runnable) {
+ mContext.getMainExecutor().execute(runnable);
+ }
+
+ private boolean isBroadcasting() {
+ return mBroadcast != null && mBroadcast.isEnabled(null);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHelper.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHelper.java
new file mode 100644
index 0000000..69001aa
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHelper.java
@@ -0,0 +1,62 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import android.graphics.Typeface;
+import android.util.Log;
+import android.util.TypedValue;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+public class AudioSharingDialogHelper {
+ private static final String TAG = "AudioSharingDialogHelper";
+
+ /** Updates the alert dialog message style. */
+ public static void updateMessageStyle(@NonNull AlertDialog dialog) {
+ TextView messageView = dialog.findViewById(android.R.id.message);
+ if (messageView != null) {
+ Typeface typeface = Typeface.create(Typeface.DEFAULT_FAMILY, Typeface.NORMAL);
+ messageView.setTypeface(typeface);
+ messageView.setTextDirection(View.TEXT_DIRECTION_LOCALE);
+ messageView.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
+ messageView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14);
+ } else {
+ Log.w(TAG, "Fail to update dialog: message view is null");
+ }
+ }
+
+ /** Returns the alert dialog by tag if it is showing. */
+ @Nullable
+ public static AlertDialog getDialogIfShowing(
+ @NonNull FragmentManager manager, @NonNull String tag) {
+ Fragment dialog = manager.findFragmentByTag(tag);
+ return dialog != null
+ && dialog instanceof DialogFragment
+ && ((DialogFragment) dialog).getDialog() != null
+ && ((DialogFragment) dialog).getDialog().isShowing()
+ && ((DialogFragment) dialog).getDialog() instanceof AlertDialog
+ ? (AlertDialog) ((DialogFragment) dialog).getDialog()
+ : null;
+ }
+}
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..e859693
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java
@@ -0,0 +1,152 @@
+/*
+ * 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.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.android.settings.R;
+import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+
+import java.util.List;
+import java.util.Locale;
+
+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);
+ }
+
+ @Nullable private static DialogEventListener sListener;
+ @Nullable private static CachedBluetoothDevice sNewDevice;
+
+ @Override
+ public int getMetricsCategory() {
+ return SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE;
+ }
+
+ /**
+ * Display the {@link AudioSharingDisconnectDialogFragment} dialog.
+ *
+ * <p>If the dialog is showing for the same group, update the dialog event listener.
+ *
+ * @param host The Fragment this dialog will be hosted.
+ * @param deviceItems The existing connected device items in audio sharing session.
+ * @param newDevice The latest connected device triggered this dialog.
+ * @param listener The callback to handle the user action on this dialog.
+ */
+ public static void show(
+ @NonNull Fragment host,
+ @NonNull List<AudioSharingDeviceItem> deviceItems,
+ @NonNull CachedBluetoothDevice newDevice,
+ @NonNull DialogEventListener listener) {
+ if (!AudioSharingUtils.isFeatureEnabled()) return;
+ FragmentManager manager = host.getChildFragmentManager();
+ AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG);
+ if (dialog != null) {
+ int newGroupId = AudioSharingUtils.getGroupId(newDevice);
+ if (sNewDevice != null && newGroupId == AudioSharingUtils.getGroupId(sNewDevice)) {
+ Log.d(
+ TAG,
+ String.format(
+ Locale.US,
+ "Dialog is showing for the same device group %d, "
+ + "update the content.",
+ newGroupId));
+ sListener = listener;
+ sNewDevice = newDevice;
+ return;
+ } else {
+ Log.d(
+ TAG,
+ String.format(
+ Locale.US,
+ "Dialog is showing for new device group %d, "
+ + "dismiss current dialog.",
+ newGroupId));
+ dialog.dismiss();
+ }
+ }
+ sListener = listener;
+ sNewDevice = newDevice;
+ Log.d(TAG, "Show up the dialog.");
+ final Bundle bundle = new Bundle();
+ bundle.putParcelableList(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, deviceItems);
+ bundle.putString(BUNDLE_KEY_NEW_DEVICE_NAME, newDevice.getName());
+ AudioSharingDisconnectDialogFragment dialogFrag =
+ new AudioSharingDisconnectDialogFragment();
+ dialogFrag.setArguments(bundle);
+ dialogFrag.show(manager, TAG);
+ }
+
+ /** Return the tag of {@link AudioSharingDisconnectDialogFragment} dialog. */
+ public static @NonNull String tag() {
+ return TAG;
+ }
+
+ /** Get the latest connected device which triggers the dialog. */
+ public @Nullable CachedBluetoothDevice getDevice() {
+ return sNewDevice;
+ }
+
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ Bundle arguments = requireArguments();
+ List<AudioSharingDeviceItem> deviceItems =
+ arguments.getParcelable(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, List.class);
+ return AudioSharingDialogFactory.newBuilder(getActivity())
+ .setTitle(R.string.audio_sharing_disconnect_dialog_title)
+ .setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
+ .setIsCustomBodyEnabled(true)
+ .setCustomMessage(R.string.audio_sharing_dialog_disconnect_content)
+ .setCustomDeviceActions(
+ new AudioSharingDeviceAdapter(
+ getContext(),
+ deviceItems,
+ (AudioSharingDeviceItem item) -> {
+ if (sListener != null) {
+ sListener.onItemClick(item);
+ }
+ dismiss();
+ },
+ AudioSharingDeviceAdapter.ActionType.REMOVE))
+ .setCustomNegativeButton(com.android.settings.R.string.cancel, v -> dismiss())
+ .build();
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingFeatureProvider.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingFeatureProvider.java
deleted file mode 100644
index 50812e9..0000000
--- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingFeatureProvider.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * 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.settings.connecteddevice.audiosharing;
-
-import android.content.Context;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.android.settings.dashboard.DashboardFragment;
-import com.android.settingslib.bluetooth.CachedBluetoothDevice;
-import com.android.settingslib.bluetooth.LocalBluetoothManager;
-import com.android.settingslib.core.AbstractPreferenceController;
-import com.android.settingslib.core.lifecycle.Lifecycle;
-
-/** Feature provider for the audio sharing related features, */
-public interface AudioSharingFeatureProvider {
-
- /** Create audio sharing device preference controller. */
- @Nullable
- AbstractPreferenceController createAudioSharingDevicePreferenceController(
- @NonNull Context context,
- @Nullable DashboardFragment fragment,
- @Nullable Lifecycle lifecycle);
-
- /** Create available media device preference controller. */
- AbstractPreferenceController createAvailableMediaDeviceGroupController(
- @NonNull Context context,
- @Nullable DashboardFragment fragment,
- @Nullable Lifecycle lifecycle);
-
- /**
- * Check if the device match the audio sharing filter.
- *
- * <p>The filter is used to filter device in "Media devices" section.
- */
- boolean isAudioSharingFilterMatched(
- @NonNull CachedBluetoothDevice cachedDevice, LocalBluetoothManager localBtManager);
-}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingFeatureProviderImpl.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingFeatureProviderImpl.java
deleted file mode 100644
index 96200db..0000000
--- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingFeatureProviderImpl.java
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * 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.settings.connecteddevice.audiosharing;
-
-import android.content.Context;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.android.settings.connecteddevice.AvailableMediaDeviceGroupController;
-import com.android.settings.dashboard.DashboardFragment;
-import com.android.settingslib.bluetooth.CachedBluetoothDevice;
-import com.android.settingslib.bluetooth.LocalBluetoothManager;
-import com.android.settingslib.core.AbstractPreferenceController;
-import com.android.settingslib.core.lifecycle.Lifecycle;
-
-public class AudioSharingFeatureProviderImpl implements AudioSharingFeatureProvider {
-
- @Nullable
- @Override
- public AbstractPreferenceController createAudioSharingDevicePreferenceController(
- @NonNull Context context,
- @Nullable DashboardFragment fragment,
- @Nullable Lifecycle lifecycle) {
- return null;
- }
-
- @Override
- public AbstractPreferenceController createAvailableMediaDeviceGroupController(
- @NonNull Context context,
- @Nullable DashboardFragment fragment,
- @Nullable Lifecycle lifecycle) {
- return new AvailableMediaDeviceGroupController(context, fragment, lifecycle);
- }
-
- @Override
- public boolean isAudioSharingFilterMatched(
- @NonNull CachedBluetoothDevice cachedDevice, LocalBluetoothManager localBtManager) {
- return false;
- }
-}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java
new file mode 100644
index 0000000..0a5961d
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java
@@ -0,0 +1,159 @@
+/*
+ * 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.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+
+import java.util.List;
+
+public class AudioSharingJoinDialogFragment extends InstrumentedDialogFragment {
+ private static final String TAG = "AudioSharingJoinDialog";
+
+ private static final String BUNDLE_KEY_DEVICE_ITEMS = "bundle_key_device_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 share audio button in the dialog. */
+ void onShareClick();
+
+ /** Called when users click the cancel button in the dialog. */
+ void onCancelClick();
+ }
+
+ @Nullable private static DialogEventListener sListener;
+ @Nullable private static CachedBluetoothDevice sNewDevice;
+
+ @Override
+ public int getMetricsCategory() {
+ return AudioSharingUtils.isBroadcasting(Utils.getLocalBtManager(getContext()))
+ ? SettingsEnums.DIALOG_START_AUDIO_SHARING
+ : SettingsEnums.DIALOG_START_AUDIO_SHARING;
+ }
+
+ /**
+ * Display the {@link AudioSharingJoinDialogFragment} dialog.
+ *
+ * <p>If the dialog is showing, update the dialog message and event listener.
+ *
+ * @param host The Fragment this dialog will be hosted.
+ * @param deviceItems The existing connected device items eligible for audio sharing.
+ * @param newDevice The latest connected device triggered this dialog.
+ * @param listener The callback to handle the user action on this dialog.
+ */
+ public static void show(
+ @NonNull Fragment host,
+ @NonNull List<AudioSharingDeviceItem> deviceItems,
+ @NonNull CachedBluetoothDevice newDevice,
+ @NonNull DialogEventListener listener) {
+ if (!AudioSharingUtils.isFeatureEnabled()) return;
+ final FragmentManager manager = host.getChildFragmentManager();
+ sListener = listener;
+ sNewDevice = newDevice;
+ AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG);
+ if (dialog != null) {
+ Log.d(TAG, "Dialog is showing, update the content.");
+ updateDialog(deviceItems, newDevice.getName(), dialog);
+ } else {
+ Log.d(TAG, "Show up the dialog.");
+ final Bundle bundle = new Bundle();
+ bundle.putParcelableList(BUNDLE_KEY_DEVICE_ITEMS, deviceItems);
+ bundle.putString(BUNDLE_KEY_NEW_DEVICE_NAME, newDevice.getName());
+ final AudioSharingJoinDialogFragment dialogFrag = new AudioSharingJoinDialogFragment();
+ dialogFrag.setArguments(bundle);
+ dialogFrag.show(manager, TAG);
+ }
+ }
+
+ /** Return the tag of {@link AudioSharingJoinDialogFragment} dialog. */
+ public static @NonNull String tag() {
+ return TAG;
+ }
+
+ /** Get the latest connected device which triggers the dialog. */
+ public @Nullable CachedBluetoothDevice getDevice() {
+ return sNewDevice;
+ }
+
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ Bundle arguments = requireArguments();
+ List<AudioSharingDeviceItem> deviceItems =
+ arguments.getParcelable(BUNDLE_KEY_DEVICE_ITEMS, List.class);
+ String newDeviceName = arguments.getString(BUNDLE_KEY_NEW_DEVICE_NAME);
+ AlertDialog dialog =
+ AudioSharingDialogFactory.newBuilder(getActivity())
+ .setTitle(R.string.audio_sharing_share_dialog_title)
+ .setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
+ .setIsCustomBodyEnabled(true)
+ .setCustomMessage(R.string.audio_sharing_dialog_share_content)
+ .setCustomPositiveButton(
+ R.string.audio_sharing_share_button_label,
+ v -> {
+ if (sListener != null) {
+ sListener.onShareClick();
+ }
+ dismiss();
+ })
+ .setCustomNegativeButton(
+ R.string.audio_sharing_no_thanks_button_label,
+ v -> {
+ if (sListener != null) {
+ sListener.onCancelClick();
+ }
+ dismiss();
+ })
+ .build();
+ updateDialog(deviceItems, newDeviceName, dialog);
+ dialog.show();
+ AudioSharingDialogHelper.updateMessageStyle(dialog);
+ return dialog;
+ }
+
+ private static void updateDialog(
+ List<AudioSharingDeviceItem> deviceItems,
+ String newDeviceName,
+ @NonNull AlertDialog dialog) {
+ // Only dialog message can be updated when the dialog is showing.
+ // Thus we put the device name for sharing as the dialog message.
+ if (deviceItems.isEmpty()) {
+ dialog.setMessage(newDeviceName);
+ } else {
+ dialog.setMessage(
+ dialog.getContext()
+ .getString(
+ R.string.audio_sharing_share_dialog_subtitle,
+ deviceItems.get(0).getName(),
+ newDeviceName));
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreference.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreference.java
new file mode 100644
index 0000000..0bb6b60
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreference.java
@@ -0,0 +1,110 @@
+/*
+ * 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.settings.SettingsEnums;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.widget.ImageButton;
+
+import androidx.preference.PreferenceViewHolder;
+
+import com.android.settings.R;
+import com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsQrCodeFragment;
+import com.android.settings.core.SubSettingLauncher;
+import com.android.settings.widget.ValidatedEditTextPreference;
+
+public class AudioSharingNamePreference extends ValidatedEditTextPreference {
+ private static final String TAG = "AudioSharingNamePreference";
+ private boolean mShowQrCodeIcon = false;
+
+ public AudioSharingNamePreference(
+ Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ initialize();
+ }
+
+ public AudioSharingNamePreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ initialize();
+ }
+
+ public AudioSharingNamePreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ initialize();
+ }
+
+ public AudioSharingNamePreference(Context context) {
+ super(context);
+ initialize();
+ }
+
+ private void initialize() {
+ setLayoutResource(
+ com.android.settingslib.widget.preference.twotarget.R.layout.preference_two_target);
+ setWidgetLayoutResource(R.layout.preference_widget_qrcode);
+ }
+
+ void setShowQrCodeIcon(boolean show) {
+ mShowQrCodeIcon = show;
+ notifyChanged();
+ }
+
+ @Override
+ public void onBindViewHolder(PreferenceViewHolder holder) {
+ super.onBindViewHolder(holder);
+
+ ImageButton shareButton = (ImageButton) holder.findViewById(R.id.button_icon);
+ View divider =
+ holder.findViewById(
+ com.android.settingslib.widget.preference.twotarget.R.id
+ .two_target_divider);
+
+ if (shareButton != null && divider != null) {
+ if (mShowQrCodeIcon) {
+ configureVisibleStateForQrCodeIcon(shareButton, divider);
+ } else {
+ configureInvisibleStateForQrCodeIcon(shareButton, divider);
+ }
+ } else {
+ Log.w(TAG, "onBindViewHolder() : shareButton or divider is null!");
+ }
+ }
+
+ private void configureVisibleStateForQrCodeIcon(ImageButton shareButton, View divider) {
+ divider.setVisibility(View.VISIBLE);
+ shareButton.setVisibility(View.VISIBLE);
+ shareButton.setImageDrawable(getContext().getDrawable(R.drawable.ic_qrcode_24dp));
+ shareButton.setOnClickListener(unused -> launchAudioSharingQrCodeFragment());
+ }
+
+ private void configureInvisibleStateForQrCodeIcon(ImageButton shareButton, View divider) {
+ divider.setVisibility(View.INVISIBLE);
+ shareButton.setVisibility(View.INVISIBLE);
+ shareButton.setOnClickListener(null);
+ }
+
+ private void launchAudioSharingQrCodeFragment() {
+ new SubSettingLauncher(getContext())
+ .setTitleText(getContext().getString(R.string.audio_streams_qr_code_page_title))
+ .setDestination(AudioStreamsQrCodeFragment.class.getName())
+ .setSourceMetricsCategory(SettingsEnums.AUDIO_SHARING_SETTINGS)
+ .launch();
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceController.java
new file mode 100644
index 0000000..2ab7b80
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNamePreferenceController.java
@@ -0,0 +1,275 @@
+/*
+ * 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 static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.isBroadcasting;
+
+import android.bluetooth.BluetoothLeBroadcast;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settings.widget.ValidatedEditTextPreference;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class AudioSharingNamePreferenceController extends BasePreferenceController
+ implements ValidatedEditTextPreference.Validator,
+ Preference.OnPreferenceChangeListener,
+ DefaultLifecycleObserver,
+ LocalBluetoothProfileManager.ServiceListener {
+
+ private static final String TAG = "AudioSharingNamePreferenceController";
+ private static final boolean DEBUG = BluetoothUtils.D;
+ private static final String PREF_KEY = "audio_sharing_stream_name";
+
+ private final BluetoothLeBroadcast.Callback mBroadcastCallback =
+ new BluetoothLeBroadcast.Callback() {
+ @Override
+ public void onBroadcastMetadataChanged(
+ int broadcastId, BluetoothLeBroadcastMetadata metadata) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "onBroadcastMetadataChanged() broadcastId : "
+ + broadcastId
+ + " metadata: "
+ + metadata);
+ }
+ updateQrCodeIcon(true);
+ }
+
+ @Override
+ public void onBroadcastStartFailed(int reason) {}
+
+ @Override
+ public void onBroadcastStarted(int reason, int broadcastId) {}
+
+ @Override
+ public void onBroadcastStopFailed(int reason) {}
+
+ @Override
+ public void onBroadcastStopped(int reason, int broadcastId) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "onBroadcastStopped() reason : "
+ + reason
+ + " broadcastId: "
+ + broadcastId);
+ }
+ updateQrCodeIcon(false);
+ }
+
+ @Override
+ public void onBroadcastUpdateFailed(int reason, int broadcastId) {
+ Log.w(TAG, "onBroadcastUpdateFailed() reason : " + reason);
+ }
+
+ @Override
+ public void onBroadcastUpdated(int reason, int broadcastId) {
+ if (DEBUG) {
+ Log.d(TAG, "onBroadcastUpdated() reason : " + reason);
+ }
+ }
+
+ @Override
+ public void onPlaybackStarted(int reason, int broadcastId) {}
+
+ @Override
+ public void onPlaybackStopped(int reason, int broadcastId) {}
+ };
+
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final LocalBluetoothProfileManager mProfileManager;
+ @Nullable private final LocalBluetoothLeBroadcast mBroadcast;
+ @Nullable private AudioSharingNamePreference mPreference;
+ private final Executor mExecutor;
+ private final AudioSharingNameTextValidator mAudioSharingNameTextValidator;
+ private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false);
+
+ public AudioSharingNamePreferenceController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ mBtManager = Utils.getLocalBluetoothManager(context);
+ mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
+ mBroadcast =
+ (mProfileManager != null) ? mProfileManager.getLeAudioBroadcastProfile() : null;
+ mAudioSharingNameTextValidator = new AudioSharingNameTextValidator();
+ mExecutor = Executors.newSingleThreadExecutor();
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip register callbacks, feature not support");
+ return;
+ }
+ if (!AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ Log.d(TAG, "Skip register callbacks, profile not ready");
+ if (mProfileManager != null) {
+ mProfileManager.addServiceListener(this);
+ }
+ return;
+ }
+ registerCallbacks();
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip unregister callbacks, feature not support");
+ return;
+ }
+ if (mProfileManager != null) {
+ mProfileManager.removeServiceListener(this);
+ }
+ if (mBroadcast == null || !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ Log.d(TAG, "Skip unregister callbacks, profile not ready");
+ return;
+ }
+ if (mCallbacksRegistered.get()) {
+ Log.d(TAG, "Unregister callbacks");
+ mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
+ mCallbacksRegistered.set(false);
+ }
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mPreference = screen.findPreference(getPreferenceKey());
+ if (mPreference != null) {
+ mPreference.setValidator(this);
+ updateBroadcastName();
+ updateQrCodeIcon(isBroadcasting(mBtManager));
+ }
+ }
+
+ @Override
+ public void onServiceConnected() {
+ if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ registerCallbacks();
+ updateBroadcastName();
+ updateQrCodeIcon(isBroadcasting(mBtManager));
+ if (mProfileManager != null) {
+ mProfileManager.removeServiceListener(this);
+ }
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected() {
+ // Do nothing
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return PREF_KEY;
+ }
+
+ @Override
+ public boolean onPreferenceChange(Preference preference, Object newValue) {
+ if (mPreference != null
+ && mPreference.getSummary() != null
+ && ((String) newValue).contentEquals(mPreference.getSummary())) {
+ return false;
+ }
+
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ if (mBroadcast != null) {
+ mBroadcast.setProgramInfo((String) newValue);
+ if (isBroadcasting(mBtManager)) {
+ mBroadcast.updateBroadcast();
+ }
+ updateBroadcastName();
+ }
+ });
+ return true;
+ }
+
+ private void registerCallbacks() {
+ if (mBroadcast == null) {
+ Log.d(TAG, "Skip register callbacks, profile not ready");
+ return;
+ }
+ if (!mCallbacksRegistered.get()) {
+ Log.d(TAG, "Register callbacks");
+ mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
+ mCallbacksRegistered.set(true);
+ }
+ }
+
+ private void updateBroadcastName() {
+ if (mPreference != null) {
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ if (mBroadcast != null) {
+ String name = mBroadcast.getProgramInfo();
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mPreference != null) {
+ mPreference.setText(name);
+ mPreference.setSummary(name);
+ }
+ });
+ }
+ });
+ }
+ }
+
+ private void updateQrCodeIcon(boolean show) {
+ if (mPreference != null) {
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mPreference != null) {
+ mPreference.setShowQrCodeIcon(show);
+ }
+ });
+ }
+ }
+
+ @Override
+ public boolean isTextValid(String value) {
+ return mAudioSharingNameTextValidator.isTextValid(value);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNameTextValidator.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNameTextValidator.java
new file mode 100644
index 0000000..2022eb2
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingNameTextValidator.java
@@ -0,0 +1,44 @@
+/*
+ * 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 com.android.settings.widget.ValidatedEditTextPreference;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Validator for Audio Sharing Name, which should be a UTF-8 encoded string containing a minimum of
+ * 4 characters and a maximum of 32 human-readable characters.
+ */
+public class AudioSharingNameTextValidator implements ValidatedEditTextPreference.Validator {
+ private static final int MIN_LENGTH = 4;
+ private static final int MAX_LENGTH = 32;
+
+ @Override
+ public boolean isTextValid(String value) {
+ if (value == null || value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) {
+ return false;
+ }
+ return isValidUTF8(value);
+ }
+
+ private static boolean isValidUTF8(String value) {
+ byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+ String reconstructedString = new String(bytes, StandardCharsets.UTF_8);
+ return value.equals(reconstructedString);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreference.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreference.java
new file mode 100644
index 0000000..e3bbfb7
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreference.java
@@ -0,0 +1,142 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static android.view.View.GONE;
+import static android.view.View.VISIBLE;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.EditText;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+
+import com.android.settings.R;
+import com.android.settings.widget.ValidatedEditTextPreference;
+import com.android.settingslib.utils.ColorUtil;
+
+public class AudioSharingPasswordPreference extends ValidatedEditTextPreference {
+ private static final String TAG = "AudioSharingPasswordPreference";
+ @Nullable private OnDialogEventListener mOnDialogEventListener;
+ @Nullable private EditText mEditText;
+ @Nullable private CheckBox mCheckBox;
+ @Nullable private View mDialogMessage;
+ private boolean mEditable = true;
+
+ interface OnDialogEventListener {
+ void onBindDialogView();
+
+ void onPreferenceDataChanged(@NonNull String editTextValue, boolean checkBoxValue);
+ }
+
+ void setOnDialogEventListener(OnDialogEventListener listener) {
+ mOnDialogEventListener = listener;
+ }
+
+ public AudioSharingPasswordPreference(
+ Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public AudioSharingPasswordPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public AudioSharingPasswordPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public AudioSharingPasswordPreference(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void onBindDialogView(View view) {
+ super.onBindDialogView(view);
+
+ mEditText = view.findViewById(android.R.id.edit);
+ mCheckBox = view.findViewById(R.id.audio_sharing_stream_password_checkbox);
+ mDialogMessage = view.findViewById(android.R.id.message);
+
+ if (mEditText == null || mCheckBox == null || mDialogMessage == null) {
+ Log.w(TAG, "onBindDialogView() : Invalid layout");
+ return;
+ }
+
+ mCheckBox.setOnCheckedChangeListener((unused, checked) -> setEditTextEnabled(!checked));
+ if (mOnDialogEventListener != null) {
+ mOnDialogEventListener.onBindDialogView();
+ }
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(
+ AlertDialog.Builder builder, DialogInterface.OnClickListener listener) {
+ if (!mEditable) {
+ builder.setPositiveButton(null, null);
+ }
+ }
+
+ @Override
+ protected void onClick(DialogInterface dialog, int which) {
+ if (mEditText == null || mCheckBox == null) {
+ Log.w(TAG, "onClick() : Invalid layout");
+ return;
+ }
+
+ if (mOnDialogEventListener != null
+ && which == DialogInterface.BUTTON_POSITIVE
+ && mEditText.getText() != null) {
+ mOnDialogEventListener.onPreferenceDataChanged(
+ mEditText.getText().toString(), mCheckBox.isChecked());
+ }
+ }
+
+ void setEditable(boolean editable) {
+ if (mEditText == null || mCheckBox == null || mDialogMessage == null) {
+ Log.w(TAG, "setEditable() : Invalid layout");
+ return;
+ }
+ mEditable = editable;
+ setEditTextEnabled(editable);
+ mCheckBox.setEnabled(editable);
+ mDialogMessage.setVisibility(editable ? GONE : VISIBLE);
+ }
+
+ void setChecked(boolean checked) {
+ if (mCheckBox == null) {
+ Log.w(TAG, "setChecked() : Invalid layout");
+ return;
+ }
+ mCheckBox.setChecked(checked);
+ }
+
+ private void setEditTextEnabled(boolean enabled) {
+ if (mEditText == null) {
+ Log.w(TAG, "setEditTextEnabled() : Invalid layout");
+ return;
+ }
+ mEditText.setEnabled(enabled);
+ mEditText.setAlpha(enabled ? 1.0f : ColorUtil.getDisabledAlpha(getContext()));
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceController.java
new file mode 100644
index 0000000..7c58c43
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordPreferenceController.java
@@ -0,0 +1,178 @@
+/*
+ * 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 static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.isBroadcasting;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settings.widget.ValidatedEditTextPreference;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+import java.nio.charset.StandardCharsets;
+
+public class AudioSharingPasswordPreferenceController extends BasePreferenceController
+ implements ValidatedEditTextPreference.Validator,
+ AudioSharingPasswordPreference.OnDialogEventListener {
+
+ private static final String TAG = "AudioSharingPasswordPreferenceController";
+ private static final String PREF_KEY = "audio_sharing_stream_password";
+ private static final String SHARED_PREF_NAME = "audio_sharing_settings";
+ private static final String SHARED_PREF_KEY = "default_password";
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final LocalBluetoothLeBroadcast mBroadcast;
+ @Nullable private AudioSharingPasswordPreference mPreference;
+ private final AudioSharingPasswordValidator mAudioSharingPasswordValidator;
+
+ public AudioSharingPasswordPreferenceController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ mBtManager = Utils.getLocalBluetoothManager(context);
+ mBroadcast =
+ mBtManager != null
+ ? mBtManager.getProfileManager().getLeAudioBroadcastProfile()
+ : null;
+ mAudioSharingPasswordValidator = new AudioSharingPasswordValidator();
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mPreference = screen.findPreference(getPreferenceKey());
+ if (mPreference != null) {
+ mPreference.setValidator(this);
+ mPreference.setIsPassword(true);
+ mPreference.setDialogLayoutResource(R.layout.audio_sharing_password_dialog);
+ mPreference.setOnDialogEventListener(this);
+ updatePreference();
+ }
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return PREF_KEY;
+ }
+
+ @Override
+ public boolean isTextValid(String value) {
+ return mAudioSharingPasswordValidator.isTextValid(value);
+ }
+
+ @Override
+ public void onBindDialogView() {
+ if (mPreference == null || mBroadcast == null) {
+ return;
+ }
+ mPreference.setEditable(!isBroadcasting(mBtManager));
+ var password = mBroadcast.getBroadcastCode();
+ mPreference.setChecked(password == null || password.length == 0);
+ }
+
+ @Override
+ public void onPreferenceDataChanged(@NonNull String password, boolean isPublicBroadcast) {
+ if (mBroadcast == null || isBroadcasting(mBtManager)) {
+ Log.w(TAG, "onPreferenceDataChanged() changing password when broadcasting or null!");
+ return;
+ }
+ persistDefaultPassword(mContext, password);
+ mBroadcast.setBroadcastCode(isPublicBroadcast ? new byte[0] : password.getBytes());
+ updatePreference();
+ }
+
+ private void updatePreference() {
+ if (mBroadcast == null || mPreference == null) {
+ return;
+ }
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ byte[] password = mBroadcast.getBroadcastCode();
+ boolean noPassword = (password == null || password.length == 0);
+ String passwordToDisplay =
+ noPassword
+ ? getDefaultPassword(mContext)
+ : new String(password, StandardCharsets.UTF_8);
+ String passwordSummary =
+ noPassword
+ ? mContext.getString(
+ R.string.audio_streams_no_password_summary)
+ : "********";
+
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ // Check nullability to pass NullAway check
+ if (mPreference != null) {
+ mPreference.setText(passwordToDisplay);
+ mPreference.setSummary(passwordSummary);
+ }
+ });
+ });
+ }
+
+ private static void persistDefaultPassword(Context context, String defaultPassword) {
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ if (getDefaultPassword(context).equals(defaultPassword)) {
+ return;
+ }
+
+ SharedPreferences sharedPref =
+ context.getSharedPreferences(
+ SHARED_PREF_NAME, Context.MODE_PRIVATE);
+ if (sharedPref == null) {
+ Log.w(TAG, "persistDefaultPassword(): sharedPref is empty!");
+ return;
+ }
+
+ SharedPreferences.Editor editor = sharedPref.edit();
+ editor.putString(SHARED_PREF_KEY, defaultPassword);
+ editor.apply();
+ });
+ }
+
+ private static String getDefaultPassword(Context context) {
+ SharedPreferences sharedPref =
+ context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE);
+ if (sharedPref == null) {
+ Log.w(TAG, "getDefaultPassword(): sharedPref is empty!");
+ return "";
+ }
+
+ String value = sharedPref.getString(SHARED_PREF_KEY, "");
+ if (value != null && value.isEmpty()) {
+ Log.w(TAG, "getDefaultPassword(): default password is empty!");
+ }
+ return value;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordValidator.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordValidator.java
new file mode 100644
index 0000000..dbb40ec
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPasswordValidator.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.connecteddevice.audiosharing;
+
+import com.android.settings.widget.ValidatedEditTextPreference;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Validator for Audio Sharing Password, which should be a UTF-8 string that has at least 4 octets
+ * and should not exceed 16 octets.
+ */
+public class AudioSharingPasswordValidator implements ValidatedEditTextPreference.Validator {
+ private static final int MIN_OCTETS = 4;
+ private static final int MAX_OCTETS = 16;
+
+ @Override
+ public boolean isTextValid(String value) {
+ if (value == null
+ || getOctetsCount(value) < MIN_OCTETS
+ || getOctetsCount(value) > MAX_OCTETS) {
+ return false;
+ }
+
+ return isValidUTF8(value);
+ }
+
+ private static int getOctetsCount(String value) {
+ return value.getBytes(StandardCharsets.UTF_8).length;
+ }
+
+ private static boolean isValidUTF8(String value) {
+ byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+ String reconstructedString = new String(bytes, StandardCharsets.UTF_8);
+ return value.equals(reconstructedString);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPlaySoundPreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPlaySoundPreferenceController.java
new file mode 100644
index 0000000..e6e11af
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPlaySoundPreferenceController.java
@@ -0,0 +1,112 @@
+/*
+ * 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.content.ContentResolver;
+import android.content.Context;
+import android.media.AudioAttributes;
+import android.media.AudioManager;
+import android.media.Ringtone;
+import android.media.RingtoneManager;
+import android.net.Uri;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+
+public class AudioSharingPlaySoundPreferenceController
+ extends AudioSharingBasePreferenceController {
+
+ private static final String TAG = "AudioSharingPlaySoundPreferenceController";
+
+ private static final String PREF_KEY = "audio_sharing_play_sound";
+
+ private Ringtone mRingtone;
+
+ public AudioSharingPlaySoundPreferenceController(Context context) {
+ super(context, PREF_KEY);
+ mRingtone = RingtoneManager.getRingtone(context, getMediaVolumeUri());
+ if (mRingtone != null) {
+ mRingtone.setStreamType(AudioManager.STREAM_MUSIC);
+ }
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return (mRingtone != null && AudioSharingUtils.isFeatureEnabled())
+ ? AVAILABLE
+ : UNSUPPORTED_ON_DEVICE;
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ if (mPreference != null) {
+ mPreference.setOnPreferenceClickListener(
+ (v) -> {
+ if (mRingtone == null) {
+ Log.d(TAG, "Skip onClick due to ringtone is null");
+ return true;
+ }
+ try {
+ mRingtone.setAudioAttributes(
+ new AudioAttributes.Builder(mRingtone.getAudioAttributes())
+ .setFlags(AudioAttributes.FLAG_BYPASS_MUTE)
+ .addTag("VX_AOSP_SAMPLESOUND")
+ .build());
+ if (!mRingtone.isPlaying()) {
+ mRingtone.play();
+ }
+ } catch (Throwable e) {
+ Log.w(TAG, "Fail to play sample, error = " + e);
+ }
+ return true;
+ });
+ }
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ super.onStop(owner);
+ if (mRingtone != null && mRingtone.isPlaying()) {
+ mRingtone.stop();
+ }
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return PREF_KEY;
+ }
+
+ @VisibleForTesting
+ protected void setRingtone(Ringtone ringtone) {
+ mRingtone = ringtone;
+ }
+
+ private Uri getMediaVolumeUri() {
+ return Uri.parse(
+ ContentResolver.SCHEME_ANDROID_RESOURCE
+ + "://"
+ + mContext.getPackageName()
+ + "/"
+ + R.raw.media_volume);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceController.java
new file mode 100644
index 0000000..54eb722
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceController.java
@@ -0,0 +1,168 @@
+/*
+ * 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.bluetooth.BluetoothLeBroadcast;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.BluetoothEventManager;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+public class AudioSharingPreferenceController extends BasePreferenceController
+ implements DefaultLifecycleObserver, BluetoothCallback {
+ private static final String TAG = "AudioSharingPreferenceController";
+
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final BluetoothEventManager mEventManager;
+ @Nullable private final LocalBluetoothLeBroadcast mBroadcast;
+ @Nullable private Preference mPreference;
+ private final Executor mExecutor;
+
+ private final BluetoothLeBroadcast.Callback mBroadcastCallback =
+ new BluetoothLeBroadcast.Callback() {
+ @Override
+ public void onBroadcastStarted(int reason, int broadcastId) {
+ refreshSummary();
+ }
+
+ @Override
+ public void onBroadcastStartFailed(int reason) {}
+
+ @Override
+ public void onBroadcastMetadataChanged(
+ int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) {}
+
+ @Override
+ public void onBroadcastStopped(int reason, int broadcastId) {
+ refreshSummary();
+ }
+
+ @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) {}
+ };
+
+ public AudioSharingPreferenceController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ mBtManager = Utils.getLocalBtManager(context);
+ mEventManager = mBtManager == null ? null : mBtManager.getEventManager();
+ mBroadcast =
+ mBtManager == null
+ ? null
+ : mBtManager.getProfileManager().getLeAudioBroadcastProfile();
+ mExecutor = Executors.newSingleThreadExecutor();
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip register callbacks, feature not support");
+ return;
+ }
+ if (mEventManager == null || mBroadcast == null) {
+ Log.d(TAG, "Skip register callbacks, profile not ready");
+ return;
+ }
+ mEventManager.registerCallback(this);
+ mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip unregister callbacks, feature not support");
+ return;
+ }
+ if (mEventManager == null || mBroadcast == null) {
+ Log.d(TAG, "Skip register callbacks, profile not ready");
+ return;
+ }
+ mEventManager.unregisterCallback(this);
+ mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
+ }
+
+ @Override
+ public void displayPreference(@NonNull PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mPreference = screen.findPreference(getPreferenceKey());
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
+ }
+
+ @Override
+ public CharSequence getSummary() {
+ return AudioSharingUtils.isBroadcasting(mBtManager)
+ ? mContext.getString(R.string.audio_sharing_summary_on)
+ : mContext.getString(R.string.audio_sharing_summary_off);
+ }
+
+ @Override
+ public void onBluetoothStateChanged(@AdapterState int bluetoothState) {
+ refreshSummary();
+ }
+
+ private void refreshSummary() {
+ if (mPreference == null) {
+ return;
+ }
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ final CharSequence summary = getSummary();
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ // Check nullability to pass NullAway check
+ if (mPreference != null) {
+ mPreference.setSummary(summary);
+ }
+ });
+ });
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingReceiver.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingReceiver.java
new file mode 100644
index 0000000..eda4256
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingReceiver.java
@@ -0,0 +1,149 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.core.app.NotificationCompat;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+
+public class AudioSharingReceiver extends BroadcastReceiver {
+ private static final String TAG = "AudioSharingNotification";
+ private static final String ACTION_LE_AUDIO_SHARING_SETTINGS =
+ "com.android.settings.BLUETOOTH_AUDIO_SHARING_SETTINGS";
+ private static final String ACTION_LE_AUDIO_SHARING_STOP =
+ "com.android.settings.action.BLUETOOTH_LE_AUDIO_SHARING_STOP";
+ private static final String CHANNEL_ID = "bluetooth_notification_channel";
+ private static final int NOTIFICATION_ID =
+ com.android.settingslib.R.drawable.ic_bt_le_audio_sharing;
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (!AudioSharingUtils.isFeatureEnabled()) {
+ Log.w(TAG, "Skip handling received intent, flag is off.");
+ return;
+ }
+ String action = intent.getAction();
+ if (action == null) {
+ Log.w(TAG, "Received unexpected intent with null action.");
+ return;
+ }
+ switch (action) {
+ case LocalBluetoothLeBroadcast.ACTION_LE_AUDIO_SHARING_STATE_CHANGE:
+ int state =
+ intent.getIntExtra(
+ LocalBluetoothLeBroadcast.EXTRA_LE_AUDIO_SHARING_STATE, -1);
+ if (state == LocalBluetoothLeBroadcast.BROADCAST_STATE_ON) {
+ showSharingNotification(context);
+ } else if (state == LocalBluetoothLeBroadcast.BROADCAST_STATE_OFF) {
+ cancelSharingNotification(context);
+ } else {
+ Log.w(
+ TAG,
+ "Skip handling ACTION_LE_AUDIO_SHARING_STATE_CHANGE, invalid extras.");
+ }
+ break;
+ case ACTION_LE_AUDIO_SHARING_STOP:
+ LocalBluetoothManager manager = Utils.getLocalBtManager(context);
+ AudioSharingUtils.stopBroadcasting(manager);
+ break;
+ default:
+ Log.w(TAG, "Received unexpected intent " + intent.getAction());
+ }
+ }
+
+ private void showSharingNotification(Context context) {
+ NotificationManager nm = context.getSystemService(NotificationManager.class);
+ if (nm.getNotificationChannel(CHANNEL_ID) == null) {
+ Log.d(TAG, "Create bluetooth notification channel");
+ NotificationChannel notificationChannel =
+ new NotificationChannel(
+ CHANNEL_ID,
+ context.getString(com.android.settings.R.string.bluetooth),
+ NotificationManager.IMPORTANCE_HIGH);
+ nm.createNotificationChannel(notificationChannel);
+ }
+ Intent stopIntent =
+ new Intent(ACTION_LE_AUDIO_SHARING_STOP).setPackage(context.getPackageName());
+ PendingIntent stopPendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ R.string.audio_sharing_stop_button_label,
+ stopIntent,
+ PendingIntent.FLAG_IMMUTABLE);
+ Intent settingsIntent =
+ new Intent(ACTION_LE_AUDIO_SHARING_SETTINGS).setPackage(context.getPackageName());
+ PendingIntent settingsPendingIntent =
+ PendingIntent.getActivity(
+ context,
+ R.string.audio_sharing_settings_button_label,
+ settingsIntent,
+ PendingIntent.FLAG_IMMUTABLE);
+ NotificationCompat.Action stopAction =
+ new NotificationCompat.Action.Builder(
+ 0,
+ context.getString(R.string.audio_sharing_stop_button_label),
+ stopPendingIntent)
+ .build();
+ NotificationCompat.Action settingsAction =
+ new NotificationCompat.Action.Builder(
+ 0,
+ context.getString(R.string.audio_sharing_settings_button_label),
+ settingsPendingIntent)
+ .build();
+ final Bundle extras = new Bundle();
+ extras.putString(
+ Notification.EXTRA_SUBSTITUTE_APP_NAME,
+ context.getString(R.string.audio_sharing_title));
+ NotificationCompat.Builder builder =
+ new NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
+ .setLocalOnly(true)
+ .setContentTitle(
+ context.getString(R.string.audio_sharing_notification_title))
+ .setContentText(
+ context.getString(R.string.audio_sharing_notification_content))
+ .setOngoing(true)
+ .setSilent(true)
+ .setColor(
+ context.getColor(
+ com.android.internal.R.color
+ .system_notification_accent_color))
+ .setContentIntent(settingsPendingIntent)
+ .addAction(stopAction)
+ .addAction(settingsAction)
+ .addExtras(extras);
+ nm.notify(NOTIFICATION_ID, builder.build());
+ }
+
+ private void cancelSharingNotification(Context context) {
+ NotificationManager nm = context.getSystemService(NotificationManager.class);
+ nm.cancel(NOTIFICATION_ID);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java
new file mode 100644
index 0000000..affd54a
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java
@@ -0,0 +1,162 @@
+/*
+ * 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.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.android.settings.R;
+import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+
+import com.google.common.collect.Iterables;
+
+import java.util.List;
+import java.util.Locale;
+
+public class AudioSharingStopDialogFragment extends InstrumentedDialogFragment {
+ private static final String TAG = "AudioSharingStopDialog";
+
+ 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 stop sharing button in the dialog. */
+ void onStopSharingClick();
+ }
+
+ @Nullable private static DialogEventListener sListener;
+ @Nullable private static CachedBluetoothDevice sCachedDevice;
+
+ @Override
+ public int getMetricsCategory() {
+ return SettingsEnums.DIALOG_STOP_AUDIO_SHARING;
+ }
+
+ /**
+ * Display the {@link AudioSharingStopDialogFragment} dialog.
+ *
+ * <p>If the dialog is showing, update the dialog message and event listener.
+ *
+ * @param host The Fragment this dialog will be hosted.
+ * @param deviceItems The existing connected device items in audio sharing session.
+ * @param newDevice The latest connected device triggered this dialog.
+ * @param listener The callback to handle the user action on this dialog.
+ */
+ public static void show(
+ @NonNull Fragment host,
+ @NonNull List<AudioSharingDeviceItem> deviceItems,
+ @NonNull CachedBluetoothDevice newDevice,
+ @NonNull DialogEventListener listener) {
+ if (!AudioSharingUtils.isFeatureEnabled()) return;
+ final FragmentManager manager = host.getChildFragmentManager();
+ AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG);
+ if (dialog != null) {
+ int newGroupId = AudioSharingUtils.getGroupId(newDevice);
+ if (sCachedDevice != null
+ && newGroupId == AudioSharingUtils.getGroupId(sCachedDevice)) {
+ Log.d(
+ TAG,
+ String.format(
+ Locale.US,
+ "Dialog is showing for the same device group %d, return.",
+ newGroupId));
+ sListener = listener;
+ sCachedDevice = newDevice;
+ return;
+ } else {
+ Log.d(
+ TAG,
+ String.format(
+ Locale.US,
+ "Dialog is showing for new device group %d, "
+ + "dismiss current dialog.",
+ newGroupId));
+ dialog.dismiss();
+ }
+ }
+ sListener = listener;
+ sCachedDevice = newDevice;
+ Log.d(TAG, "Show up the dialog.");
+ final Bundle bundle = new Bundle();
+ bundle.putParcelableList(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, deviceItems);
+ bundle.putString(BUNDLE_KEY_NEW_DEVICE_NAME, newDevice.getName());
+ AudioSharingStopDialogFragment dialogFrag = new AudioSharingStopDialogFragment();
+ dialogFrag.setArguments(bundle);
+ dialogFrag.show(manager, TAG);
+ }
+
+ /** Return the tag of {@link AudioSharingStopDialogFragment} dialog. */
+ public static @NonNull String tag() {
+ return TAG;
+ }
+
+ /** Get the latest connected device which triggers the dialog. */
+ public @Nullable CachedBluetoothDevice getDevice() {
+ return sCachedDevice;
+ }
+
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ Bundle arguments = requireArguments();
+ List<AudioSharingDeviceItem> deviceItems =
+ arguments.getParcelable(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, List.class);
+ String newDeviceName = arguments.getString(BUNDLE_KEY_NEW_DEVICE_NAME);
+ String customMessage =
+ deviceItems.size() == 1
+ ? getString(
+ R.string.audio_sharing_stop_dialog_content,
+ Iterables.getOnlyElement(deviceItems).getName())
+ : (deviceItems.size() == 2
+ ? getString(
+ R.string.audio_sharing_stop_dialog_with_two_content,
+ deviceItems.get(0).getName(),
+ deviceItems.get(1).getName())
+ : getString(R.string.audio_sharing_stop_dialog_with_more_content));
+ AlertDialog dialog =
+ AudioSharingDialogFactory.newBuilder(getActivity())
+ .setTitle(
+ getString(R.string.audio_sharing_stop_dialog_title, newDeviceName))
+ .setTitleIcon(com.android.settings.R.drawable.ic_warning_24dp)
+ .setIsCustomBodyEnabled(true)
+ .setCustomMessage(customMessage)
+ .setPositiveButton(
+ R.string.audio_sharing_connect_button_label,
+ (dlg, which) -> {
+ if (sListener != null) {
+ sListener.onStopSharingClick();
+ }
+ })
+ .setNegativeButton(
+ com.android.settings.R.string.cancel, (dlg, which) -> dismiss())
+ .build();
+ dialog.show();
+ AudioSharingDialogHelper.updateMessageStyle(dialog);
+ return dialog;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java
new file mode 100644
index 0000000..df49de4
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java
@@ -0,0 +1,522 @@
+/*
+ * 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.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcast;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothProfile;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.util.Log;
+import android.widget.CompoundButton;
+import android.widget.CompoundButton.OnCheckedChangeListener;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settings.widget.SettingsMainSwitchBar;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
+
+public class AudioSharingSwitchBarController extends BasePreferenceController
+ implements DefaultLifecycleObserver,
+ OnCheckedChangeListener,
+ LocalBluetoothProfileManager.ServiceListener {
+ private static final String TAG = "AudioSharingSwitchBarCtl";
+ private static final String PREF_KEY = "audio_sharing_main_switch";
+
+ interface OnAudioSharingStateChangedListener {
+ /**
+ * The callback which will be triggered when:
+ *
+ * <p>1. Bluetooth on/off state changes. 2. Broadcast and assistant profile
+ * connect/disconnect state changes. 3. Audio sharing start/stop state changes.
+ */
+ void onAudioSharingStateChanged();
+
+ /**
+ * The callback which will be triggered when:
+ *
+ * <p>Broadcast and assistant profile connected.
+ */
+ void onAudioSharingProfilesConnected();
+ }
+
+ private final SettingsMainSwitchBar mSwitchBar;
+ private final BluetoothAdapter mBluetoothAdapter;
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final LocalBluetoothProfileManager mProfileManager;
+ @Nullable private final LocalBluetoothLeBroadcast mBroadcast;
+ @Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Nullable private DashboardFragment mFragment;
+ private final Executor mExecutor;
+ private final OnAudioSharingStateChangedListener mListener;
+ private Map<Integer, List<CachedBluetoothDevice>> mGroupedConnectedDevices = new HashMap<>();
+ private List<BluetoothDevice> mTargetActiveSinks = new ArrayList<>();
+ private List<AudioSharingDeviceItem> mDeviceItemsForSharing = new ArrayList<>();
+ @VisibleForTesting IntentFilter mIntentFilter;
+ private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false);
+
+ @VisibleForTesting
+ BroadcastReceiver mReceiver =
+ new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ updateSwitch();
+ mListener.onAudioSharingStateChanged();
+ }
+ };
+
+ private final BluetoothLeBroadcast.Callback mBroadcastCallback =
+ new BluetoothLeBroadcast.Callback() {
+ @Override
+ public void onBroadcastStarted(int reason, int broadcastId) {
+ Log.d(
+ TAG,
+ "onBroadcastStarted(), reason = "
+ + reason
+ + ", broadcastId = "
+ + broadcastId);
+ updateSwitch();
+ mListener.onAudioSharingStateChanged();
+ }
+
+ @Override
+ public void onBroadcastStartFailed(int reason) {
+ Log.d(TAG, "onBroadcastStartFailed(), reason = " + reason);
+ // TODO: handle broadcast start fail
+ updateSwitch();
+ }
+
+ @Override
+ public void onBroadcastMetadataChanged(
+ int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) {
+ Log.d(
+ TAG,
+ "onBroadcastMetadataChanged(), broadcastId = "
+ + broadcastId
+ + ", metadata = "
+ + metadata.getBroadcastName());
+ }
+
+ @Override
+ public void onBroadcastStopped(int reason, int broadcastId) {
+ Log.d(
+ TAG,
+ "onBroadcastStopped(), reason = "
+ + reason
+ + ", broadcastId = "
+ + broadcastId);
+ updateSwitch();
+ mListener.onAudioSharingStateChanged();
+ }
+
+ @Override
+ public void onBroadcastStopFailed(int reason) {
+ Log.d(TAG, "onBroadcastStopFailed(), reason = " + reason);
+ // TODO: handle broadcast stop fail
+ updateSwitch();
+ }
+
+ @Override
+ public void onBroadcastUpdated(int reason, int broadcastId) {}
+
+ @Override
+ public void onBroadcastUpdateFailed(int reason, int broadcastId) {}
+
+ @Override
+ public void onPlaybackStarted(int reason, int broadcastId) {
+ Log.d(
+ TAG,
+ "onPlaybackStarted(), reason = "
+ + reason
+ + ", broadcastId = "
+ + broadcastId);
+ handleOnBroadcastReady();
+ }
+
+ @Override
+ public void onPlaybackStopped(int reason, int broadcastId) {}
+ };
+
+ private BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
+ new BluetoothLeBroadcastAssistant.Callback() {
+ @Override
+ public void onSearchStarted(int reason) {}
+
+ @Override
+ public void onSearchStartFailed(int reason) {}
+
+ @Override
+ public void onSearchStopped(int reason) {}
+
+ @Override
+ public void onSearchStopFailed(int reason) {}
+
+ @Override
+ public void onSourceFound(@NonNull BluetoothLeBroadcastMetadata source) {}
+
+ @Override
+ public void onSourceAdded(@NonNull BluetoothDevice sink, int sourceId, int reason) {
+ Log.d(
+ TAG,
+ "onSourceAdded(), sink = "
+ + sink
+ + ", sourceId = "
+ + sourceId
+ + ", reason = "
+ + reason);
+ }
+
+ @Override
+ public void onSourceAddFailed(
+ @NonNull BluetoothDevice sink,
+ @NonNull BluetoothLeBroadcastMetadata source,
+ int reason) {
+ Log.d(
+ TAG,
+ "onSourceAddFailed(), sink = "
+ + sink
+ + ", source = "
+ + source
+ + ", reason = "
+ + reason);
+ AudioSharingUtils.toastMessage(
+ mContext,
+ String.format(
+ Locale.US,
+ "Fail to add source to %s reason %d",
+ sink.getAddress(),
+ reason));
+ }
+
+ @Override
+ public void onSourceModified(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceModifyFailed(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceRemoved(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceRemoveFailed(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onReceiveStateChanged(
+ BluetoothDevice sink,
+ int sourceId,
+ BluetoothLeBroadcastReceiveState state) {}
+ };
+
+ AudioSharingSwitchBarController(
+ Context context,
+ SettingsMainSwitchBar switchBar,
+ OnAudioSharingStateChangedListener listener) {
+ super(context, PREF_KEY);
+ mSwitchBar = switchBar;
+ mListener = listener;
+ mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+ mIntentFilter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED);
+ mBtManager = Utils.getLocalBtManager(context);
+ mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
+ mBroadcast = mProfileManager == null ? null : mProfileManager.getLeAudioBroadcastProfile();
+ mAssistant =
+ mProfileManager == null
+ ? null
+ : mProfileManager.getLeAudioBroadcastAssistantProfile();
+ mExecutor = Executors.newSingleThreadExecutor();
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip register callbacks. Feature is not available.");
+ return;
+ }
+ mContext.registerReceiver(mReceiver, mIntentFilter, Context.RECEIVER_EXPORTED_UNAUDITED);
+ updateSwitch();
+ if (!AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ if (mProfileManager != null) {
+ mProfileManager.addServiceListener(this);
+ }
+ Log.d(TAG, "Skip register callbacks. Profile is not ready.");
+ return;
+ }
+ registerCallbacks();
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip unregister callbacks. Feature is not available.");
+ return;
+ }
+ mContext.unregisterReceiver(mReceiver);
+ if (mProfileManager != null) {
+ mProfileManager.removeServiceListener(this);
+ }
+ unregisterCallbacks();
+ }
+
+ @Override
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ // Filter out unnecessary callbacks when switch is disabled.
+ if (!buttonView.isEnabled()) return;
+ if (isChecked) {
+ mSwitchBar.setEnabled(false);
+ boolean isBroadcasting = AudioSharingUtils.isBroadcasting(mBtManager);
+ if (mAssistant == null || mBroadcast == null || isBroadcasting) {
+ Log.d(TAG, "Skip startAudioSharing, already broadcasting or not support.");
+ mSwitchBar.setEnabled(true);
+ if (!isBroadcasting) {
+ mSwitchBar.setChecked(false);
+ }
+ return;
+ }
+ if (mAssistant
+ .getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED})
+ .isEmpty()) {
+ // Pop up dialog to ask users to connect at least one lea buds before audio sharing.
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ mSwitchBar.setEnabled(true);
+ mSwitchBar.setChecked(false);
+ if (mFragment != null) {
+ AudioSharingConfirmDialogFragment.show(mFragment);
+ }
+ });
+ return;
+ }
+ startAudioSharing();
+ } else {
+ stopAudioSharing();
+ }
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
+ }
+
+ @Override
+ public void onServiceConnected() {
+ Log.d(TAG, "onServiceConnected()");
+ if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ registerCallbacks();
+ updateSwitch();
+ mListener.onAudioSharingProfilesConnected();
+ mListener.onAudioSharingStateChanged();
+ if (mProfileManager != null) {
+ mProfileManager.removeServiceListener(this);
+ }
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected() {
+ Log.d(TAG, "onServiceDisconnected()");
+ // Do nothing.
+ }
+
+ /**
+ * Initialize the controller.
+ *
+ * @param fragment The fragment to host the {@link AudioSharingSwitchBarController} dialog.
+ */
+ public void init(DashboardFragment fragment) {
+ this.mFragment = fragment;
+ }
+
+ /** Test only: set callback registration status in tests. */
+ @VisibleForTesting
+ public void setCallbacksRegistered(boolean registered) {
+ mCallbacksRegistered.set(registered);
+ }
+
+ private void registerCallbacks() {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip registerCallbacks(). Feature is not available.");
+ return;
+ }
+ if (mBroadcast == null || mAssistant == null) {
+ Log.d(TAG, "Skip registerCallbacks(). Profile not support on this device.");
+ return;
+ }
+ if (!mCallbacksRegistered.get()) {
+ Log.d(TAG, "registerCallbacks()");
+ mSwitchBar.addOnSwitchChangeListener(this);
+ mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
+ mAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
+ mCallbacksRegistered.set(true);
+ }
+ }
+
+ private void unregisterCallbacks() {
+ if (!isAvailable() || !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ Log.d(TAG, "Skip unregisterCallbacks(). Feature is not available.");
+ return;
+ }
+ if (mBroadcast == null || mAssistant == null) {
+ Log.d(TAG, "Skip unregisterCallbacks(). Profile not support on this device.");
+ return;
+ }
+ if (mCallbacksRegistered.get()) {
+ Log.d(TAG, "unregisterCallbacks()");
+ mSwitchBar.removeOnSwitchChangeListener(this);
+ mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
+ mAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
+ mCallbacksRegistered.set(false);
+ }
+ }
+
+ private void startAudioSharing() {
+ // Compute the device connection state before start audio sharing since the devices will
+ // be set to inactive after the broadcast started.
+ mGroupedConnectedDevices = AudioSharingUtils.fetchConnectedDevicesByGroupId(mBtManager);
+ List<AudioSharingDeviceItem> deviceItems =
+ AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem(
+ mBtManager, mGroupedConnectedDevices, /* filterByInSharing= */ false);
+ // deviceItems is ordered. The active device is the first place if exits.
+ mDeviceItemsForSharing = new ArrayList<>(deviceItems);
+ mTargetActiveSinks = new ArrayList<>();
+ if (!deviceItems.isEmpty() && deviceItems.get(0).isActive()) {
+ for (CachedBluetoothDevice device :
+ mGroupedConnectedDevices.getOrDefault(
+ deviceItems.get(0).getGroupId(), ImmutableList.of())) {
+ // If active device exists for audio sharing, share to it
+ // automatically once the broadcast is started.
+ mTargetActiveSinks.add(device.getDevice());
+ }
+ mDeviceItemsForSharing.remove(0);
+ }
+ if (mBroadcast != null) {
+ mBroadcast.startPrivateBroadcast();
+ }
+ }
+
+ private void stopAudioSharing() {
+ mSwitchBar.setEnabled(false);
+ if (!AudioSharingUtils.isBroadcasting(mBtManager)) {
+ Log.d(TAG, "Skip stopAudioSharing, already not broadcasting or broadcast not support.");
+ mSwitchBar.setEnabled(true);
+ return;
+ }
+ if (mBroadcast != null) {
+ mBroadcast.stopBroadcast(mBroadcast.getLatestBroadcastId());
+ }
+ }
+
+ private void updateSwitch() {
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ boolean isBroadcasting = AudioSharingUtils.isBroadcasting(mBtManager);
+ boolean isStateReady =
+ isBluetoothOn()
+ && AudioSharingUtils.isAudioSharingProfileReady(
+ mProfileManager);
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mSwitchBar.isChecked() != isBroadcasting) {
+ mSwitchBar.setChecked(isBroadcasting);
+ }
+ if (mSwitchBar.isEnabled() != isStateReady) {
+ mSwitchBar.setEnabled(isStateReady);
+ }
+ Log.d(
+ TAG,
+ "updateSwitch, checked = "
+ + isBroadcasting
+ + ", enabled = "
+ + isStateReady);
+ });
+ });
+ }
+
+ private boolean isBluetoothOn() {
+ return mBluetoothAdapter != null && mBluetoothAdapter.isEnabled();
+ }
+
+ private void handleOnBroadcastReady() {
+ AudioSharingUtils.addSourceToTargetSinks(mTargetActiveSinks, mBtManager);
+ mTargetActiveSinks.clear();
+ if (mFragment == null) {
+ Log.w(TAG, "Dialog fail to show due to null fragment.");
+ mGroupedConnectedDevices.clear();
+ mDeviceItemsForSharing.clear();
+ return;
+ }
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ // Check nullability to pass NullAway check
+ if (mFragment != null) {
+ AudioSharingDialogFragment.show(
+ mFragment,
+ mDeviceItemsForSharing,
+ item -> {
+ AudioSharingUtils.addSourceToTargetSinks(
+ mGroupedConnectedDevices
+ .getOrDefault(
+ item.getGroupId(), ImmutableList.of())
+ .stream()
+ .map(CachedBluetoothDevice::getDevice)
+ .collect(Collectors.toList()),
+ mBtManager);
+ mGroupedConnectedDevices.clear();
+ mDeviceItemsForSharing.clear();
+ });
+ }
+ });
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingUtils.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingUtils.java
new file mode 100644
index 0000000..f63717e
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingUtils.java
@@ -0,0 +1,386 @@
+/*
+ * 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.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothCsipSetCoordinator;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.Context;
+import android.provider.Settings;
+import android.util.Log;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.settingslib.bluetooth.BluetoothUtils;
+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.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+import com.android.settingslib.flags.Flags;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public class AudioSharingUtils {
+ public static final String SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID =
+ "bluetooth_le_broadcast_fallback_active_group_id";
+ private static final String TAG = "AudioSharingUtils";
+ private static final boolean DEBUG = BluetoothUtils.D;
+
+ /**
+ * Fetch {@link CachedBluetoothDevice}s connected to the broadcast assistant. The devices are
+ * grouped by CSIP group id.
+ *
+ * @param localBtManager The BT manager to provide BT functions.
+ * @return A map of connected devices grouped by CSIP group id.
+ */
+ public static Map<Integer, List<CachedBluetoothDevice>> fetchConnectedDevicesByGroupId(
+ @Nullable LocalBluetoothManager localBtManager) {
+ Map<Integer, List<CachedBluetoothDevice>> groupedDevices = new HashMap<>();
+ if (localBtManager == null) {
+ Log.d(TAG, "Skip fetchConnectedDevicesByGroupId due to bt manager is null");
+ return groupedDevices;
+ }
+ LocalBluetoothLeBroadcastAssistant assistant =
+ localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile();
+ if (assistant == null) {
+ Log.d(TAG, "Skip fetchConnectedDevicesByGroupId due to assistant profile is null");
+ return groupedDevices;
+ }
+ List<BluetoothDevice> connectedDevices =
+ assistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED});
+ CachedBluetoothDeviceManager cacheManager = localBtManager.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 = getGroupId(cachedDevice);
+ 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);
+ }
+ if (DEBUG) {
+ Log.d(TAG, "fetchConnectedDevicesByGroupId: " + groupedDevices);
+ }
+ return groupedDevices;
+ }
+
+ /**
+ * Fetch a list of ordered connected lead {@link CachedBluetoothDevice}s eligible for audio
+ * sharing. The active device is placed in the first place if it exists. The devices can be
+ * filtered by whether it is already in the audio sharing session.
+ *
+ * @param localBtManager The BT manager to provide BT functions. *
+ * @param groupedConnectedDevices devices connected to broadcast assistant grouped by CSIP group
+ * id.
+ * @param filterByInSharing Whether to filter the device by if is already in the sharing
+ * session.
+ * @return A list of ordered connected devices eligible for the audio sharing. The active device
+ * is placed in the first place if it exists.
+ */
+ public static List<CachedBluetoothDevice> buildOrderedConnectedLeadDevices(
+ @Nullable LocalBluetoothManager localBtManager,
+ Map<Integer, List<CachedBluetoothDevice>> groupedConnectedDevices,
+ boolean filterByInSharing) {
+ List<CachedBluetoothDevice> orderedDevices = new ArrayList<>();
+ for (List<CachedBluetoothDevice> devices : groupedConnectedDevices.values()) {
+ @Nullable CachedBluetoothDevice leadDevice = getLeadDevice(devices);
+ if (leadDevice == null) {
+ Log.d(TAG, "Skip due to no lead device");
+ continue;
+ }
+ if (filterByInSharing
+ && !BluetoothUtils.hasConnectedBroadcastSource(leadDevice, localBtManager)) {
+ Log.d(
+ TAG,
+ "Filtered the device due to not in sharing session: "
+ + leadDevice.getDevice().getAnonymizedAddress());
+ continue;
+ }
+ orderedDevices.add(leadDevice);
+ }
+ orderedDevices.sort(
+ (CachedBluetoothDevice d1, CachedBluetoothDevice d2) -> {
+ // Active above not inactive
+ int comparison =
+ (isActiveLeAudioDevice(d2) ? 1 : 0)
+ - (isActiveLeAudioDevice(d1) ? 1 : 0);
+ if (comparison != 0) return comparison;
+ // Bonded above not bonded
+ comparison =
+ (d2.getBondState() == BluetoothDevice.BOND_BONDED ? 1 : 0)
+ - (d1.getBondState() == BluetoothDevice.BOND_BONDED ? 1 : 0);
+ if (comparison != 0) return comparison;
+ // Bond timestamp available above unavailable
+ comparison =
+ (d2.getBondTimestamp() != null ? 1 : 0)
+ - (d1.getBondTimestamp() != null ? 1 : 0);
+ if (comparison != 0) return comparison;
+ // Order by bond timestamp if it is available
+ // Otherwise order by device name
+ return d1.getBondTimestamp() != null
+ ? d1.getBondTimestamp().compareTo(d2.getBondTimestamp())
+ : d1.getName().compareTo(d2.getName());
+ });
+ return orderedDevices;
+ }
+
+ /**
+ * Get the lead device from a list of devices with same group id.
+ *
+ * @param devices A list of devices with same group id.
+ * @return The lead device
+ */
+ @Nullable
+ public static CachedBluetoothDevice getLeadDevice(
+ @NonNull List<CachedBluetoothDevice> devices) {
+ if (devices.isEmpty()) return null;
+ for (CachedBluetoothDevice device : devices) {
+ if (!device.getMemberDevice().isEmpty()) {
+ return device;
+ }
+ }
+ CachedBluetoothDevice leadDevice = devices.get(0);
+ Log.d(
+ TAG,
+ "No lead device in the group, pick arbitrary device as the lead: "
+ + leadDevice.getDevice().getAnonymizedAddress());
+ return leadDevice;
+ }
+
+ /**
+ * Fetch a list of ordered connected lead {@link AudioSharingDeviceItem}s eligible for audio
+ * sharing. The active device is placed in the first place if it exists. The devices can be
+ * filtered by whether it is already in the audio sharing session.
+ *
+ * @param localBtManager The BT manager to provide BT functions. *
+ * @param groupedConnectedDevices devices connected to broadcast assistant grouped by CSIP group
+ * id.
+ * @param filterByInSharing Whether to filter the device by if is already in the sharing
+ * session.
+ * @return A list of ordered connected devices eligible for the audio sharing. The active device
+ * is placed in the first place if it exists.
+ */
+ @NonNull
+ public static List<AudioSharingDeviceItem> buildOrderedConnectedLeadAudioSharingDeviceItem(
+ @Nullable LocalBluetoothManager localBtManager,
+ Map<Integer, List<CachedBluetoothDevice>> groupedConnectedDevices,
+ boolean filterByInSharing) {
+ return buildOrderedConnectedLeadDevices(
+ localBtManager, groupedConnectedDevices, filterByInSharing)
+ .stream()
+ .map(device -> buildAudioSharingDeviceItem(device))
+ .collect(Collectors.toList());
+ }
+
+ /** Build {@link AudioSharingDeviceItem} from {@link CachedBluetoothDevice}. */
+ public static AudioSharingDeviceItem buildAudioSharingDeviceItem(
+ CachedBluetoothDevice cachedDevice) {
+ return new AudioSharingDeviceItem(
+ cachedDevice.getName(),
+ getGroupId(cachedDevice),
+ isActiveLeAudioDevice(cachedDevice));
+ }
+
+ /**
+ * Check if {@link CachedBluetoothDevice} is an active le audio device.
+ *
+ * @param cachedDevice The cached bluetooth device to check.
+ * @return Whether the device is an active le audio device.
+ */
+ public static boolean isActiveLeAudioDevice(CachedBluetoothDevice cachedDevice) {
+ return BluetoothUtils.isActiveLeAudioDevice(cachedDevice);
+ }
+
+ /** Toast message on main thread. */
+ public static void toastMessage(Context context, String message) {
+ context.getMainExecutor()
+ .execute(() -> Toast.makeText(context, message, Toast.LENGTH_LONG).show());
+ }
+
+ /** Returns if the le audio sharing is enabled. */
+ public static boolean isFeatureEnabled() {
+ BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
+ return Flags.enableLeAudioSharing()
+ && adapter.isLeAudioBroadcastSourceSupported()
+ == BluetoothStatusCodes.FEATURE_SUPPORTED
+ && adapter.isLeAudioBroadcastAssistantSupported()
+ == BluetoothStatusCodes.FEATURE_SUPPORTED;
+ }
+
+ /** Add source to target sinks. */
+ public static void addSourceToTargetSinks(
+ List<BluetoothDevice> sinks, @Nullable LocalBluetoothManager localBtManager) {
+ if (localBtManager == null) {
+ Log.d(TAG, "skip addSourceToTargetDevices: LocalBluetoothManager is null!");
+ return;
+ }
+ if (sinks.isEmpty()) {
+ Log.d(TAG, "Skip addSourceToTargetDevices. No sinks.");
+ return;
+ }
+ LocalBluetoothLeBroadcast broadcast =
+ localBtManager.getProfileManager().getLeAudioBroadcastProfile();
+ if (broadcast == null) {
+ Log.d(TAG, "skip addSourceToTargetDevices. Broadcast profile is null.");
+ return;
+ }
+ LocalBluetoothLeBroadcastAssistant assistant =
+ localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile();
+ if (assistant == null) {
+ Log.d(TAG, "skip addSourceToTargetDevices. Assistant profile is null.");
+ return;
+ }
+ BluetoothLeBroadcastMetadata broadcastMetadata =
+ broadcast.getLatestBluetoothLeBroadcastMetadata();
+ if (broadcastMetadata == null) {
+ Log.d(TAG, "skip addSourceToTargetDevices: There is no broadcastMetadata.");
+ return;
+ }
+ List<BluetoothDevice> connectedDevices =
+ assistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED});
+ for (BluetoothDevice sink : sinks) {
+ if (connectedDevices.contains(sink)) {
+ Log.d(
+ TAG,
+ "Add broadcast with broadcastId: "
+ + broadcastMetadata.getBroadcastId()
+ + " to the device: "
+ + sink.getAnonymizedAddress());
+ assistant.addSource(sink, broadcastMetadata, /* isGroupOp= */ false);
+ } else {
+ Log.d(
+ TAG,
+ "Skip add broadcast with broadcastId: "
+ + broadcastMetadata.getBroadcastId()
+ + " to the not connected device: "
+ + sink.getAnonymizedAddress());
+ }
+ }
+ }
+
+ /** Returns if the broadcast is on-going. */
+ public static boolean isBroadcasting(@Nullable LocalBluetoothManager manager) {
+ if (manager == null) return false;
+ LocalBluetoothLeBroadcast broadcast =
+ manager.getProfileManager().getLeAudioBroadcastProfile();
+ return broadcast != null && broadcast.isEnabled(null);
+ }
+
+ /** Stops the latest broadcast. */
+ public static void stopBroadcasting(@Nullable LocalBluetoothManager manager) {
+ if (manager == null) {
+ Log.d(TAG, "Skip stop broadcasting due to bt manager is null");
+ return;
+ }
+ LocalBluetoothLeBroadcast broadcast =
+ manager.getProfileManager().getLeAudioBroadcastProfile();
+ if (broadcast == null) {
+ Log.d(TAG, "Skip stop broadcasting due to broadcast profile is null");
+ }
+ broadcast.stopBroadcast(broadcast.getLatestBroadcastId());
+ }
+
+ /**
+ * Get CSIP group id for {@link CachedBluetoothDevice}.
+ *
+ * <p>If CachedBluetoothDevice#getGroupId is invalid, fetch group id from
+ * LeAudioProfile#getGroupId.
+ */
+ public static int getGroupId(CachedBluetoothDevice cachedDevice) {
+ int groupId = cachedDevice.getGroupId();
+ String anonymizedAddress = cachedDevice.getDevice().getAnonymizedAddress();
+ if (groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID) {
+ Log.d(TAG, "getGroupId by CSIP profile for device: " + anonymizedAddress);
+ return groupId;
+ }
+ for (LocalBluetoothProfile profile : cachedDevice.getProfiles()) {
+ if (profile instanceof LeAudioProfile) {
+ Log.d(TAG, "getGroupId by LEA profile for device: " + anonymizedAddress);
+ return ((LeAudioProfile) profile).getGroupId(cachedDevice.getDevice());
+ }
+ }
+ Log.d(TAG, "getGroupId return invalid id for device: " + anonymizedAddress);
+ return BluetoothCsipSetCoordinator.GROUP_ID_INVALID;
+ }
+
+ /** Get the fallback active group id from SettingsProvider. */
+ public static int getFallbackActiveGroupId(@NonNull Context context) {
+ return Settings.Secure.getInt(
+ context.getContentResolver(),
+ SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID,
+ BluetoothCsipSetCoordinator.GROUP_ID_INVALID);
+ }
+
+ /** Post the runnable to main thread. */
+ public static void postOnMainThread(@NonNull Context context, @NonNull Runnable runnable) {
+ context.getMainExecutor().execute(runnable);
+ }
+
+ /** Check if the {@link CachedBluetoothDevice} supports LE Audio profile */
+ public static boolean isLeAudioSupported(CachedBluetoothDevice cachedDevice) {
+ return cachedDevice.getProfiles().stream()
+ .anyMatch(
+ profile ->
+ profile instanceof LeAudioProfile
+ && profile.isEnabled(cachedDevice.getDevice()));
+ }
+
+ /** Check if the LE Audio related profiles ready */
+ public static boolean isAudioSharingProfileReady(
+ @Nullable LocalBluetoothProfileManager profileManager) {
+ if (profileManager == null) return false;
+ LocalBluetoothLeBroadcast broadcast = profileManager.getLeAudioBroadcastProfile();
+ if (broadcast == null || !broadcast.isProfileReady()) {
+ return false;
+ }
+ LocalBluetoothLeBroadcastAssistant assistant =
+ profileManager.getLeAudioBroadcastAssistantProfile();
+ if (assistant == null || !assistant.isProfileReady()) {
+ return false;
+ }
+ VolumeControlProfile vc = profileManager.getVolumeControlProfile();
+ if (vc == null || !vc.isProfileReady()) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsDialogFragment.java
new file mode 100644
index 0000000..df94694
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsDialogFragment.java
@@ -0,0 +1,107 @@
+/*
+ * 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 androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.android.settings.R;
+import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
+
+import java.util.List;
+
+/** Provides a dialog to choose the active device for calls and alarms. */
+public class CallsAndAlarmsDialogFragment extends InstrumentedDialogFragment {
+ private static final String TAG = "CallsAndAlarmsDialog";
+ private static final String BUNDLE_KEY_DEVICE_ITEMS = "bundle_key_device_items";
+
+ // 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 set active for calls and alarms in the dialog.
+ *
+ * @param item The device item clicked.
+ */
+ void onItemClick(AudioSharingDeviceItem item);
+ }
+
+ @Nullable private static DialogEventListener sListener;
+
+ @Override
+ public int getMetricsCategory() {
+ return SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_ACTIVE;
+ }
+
+ /**
+ * Display the {@link CallsAndAlarmsDialogFragment} dialog.
+ *
+ * @param host The Fragment this dialog will be hosted.
+ * @param deviceItems The connected device items in audio sharing session.
+ * @param listener The callback to handle the user action on this dialog.
+ */
+ public static void show(
+ @NonNull Fragment host,
+ @NonNull List<AudioSharingDeviceItem> deviceItems,
+ @NonNull DialogEventListener listener) {
+ if (!AudioSharingUtils.isFeatureEnabled()) return;
+ final FragmentManager manager = host.getChildFragmentManager();
+ sListener = listener;
+ if (manager.findFragmentByTag(TAG) == null) {
+ final Bundle bundle = new Bundle();
+ bundle.putParcelableList(BUNDLE_KEY_DEVICE_ITEMS, deviceItems);
+ final CallsAndAlarmsDialogFragment dialog = new CallsAndAlarmsDialogFragment();
+ dialog.setArguments(bundle);
+ dialog.show(manager, TAG);
+ }
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ Bundle arguments = requireArguments();
+ List<AudioSharingDeviceItem> deviceItems =
+ arguments.getParcelable(BUNDLE_KEY_DEVICE_ITEMS, List.class);
+ int checkedItem = -1;
+ for (AudioSharingDeviceItem item : deviceItems) {
+ int fallbackActiveGroupId = AudioSharingUtils.getFallbackActiveGroupId(getContext());
+ if (item.getGroupId() == fallbackActiveGroupId) {
+ checkedItem = deviceItems.indexOf(item);
+ }
+ }
+ String[] choices =
+ deviceItems.stream().map(AudioSharingDeviceItem::getName).toArray(String[]::new);
+ AlertDialog.Builder builder =
+ new AlertDialog.Builder(getActivity())
+ .setTitle(R.string.audio_sharing_call_audio_title)
+ .setSingleChoiceItems(
+ choices,
+ checkedItem,
+ (dialog, which) -> {
+ if (sListener != null) {
+ sListener.onItemClick(deviceItems.get(which));
+ }
+ });
+ return builder.create();
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsPreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsPreferenceController.java
new file mode 100644
index 0000000..8aaebc6
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsPreferenceController.java
@@ -0,0 +1,361 @@
+/*
+ * 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 static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothCsipSetCoordinator;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothProfile;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.BluetoothEventManager;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+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;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** PreferenceController to control the dialog to choose the active device for calls and alarms */
+public class CallsAndAlarmsPreferenceController extends AudioSharingBasePreferenceController
+ implements BluetoothCallback {
+ private static final String TAG = "CallsAndAlarmsPreferenceController";
+ private static final String PREF_KEY = "calls_and_alarms";
+
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final LocalBluetoothProfileManager mProfileManager;
+ @Nullable private final BluetoothEventManager mEventManager;
+ @Nullable private final ContentResolver mContentResolver;
+ @Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
+ private final Executor mExecutor;
+ private final ContentObserver mSettingsObserver;
+ @Nullable private DashboardFragment mFragment;
+ Map<Integer, List<CachedBluetoothDevice>> mGroupedConnectedDevices = new HashMap<>();
+ private List<AudioSharingDeviceItem> mDeviceItemsInSharingSession = new ArrayList<>();
+ private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false);
+ private BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
+ new BluetoothLeBroadcastAssistant.Callback() {
+ @Override
+ public void onSearchStarted(int reason) {}
+
+ @Override
+ public void onSearchStartFailed(int reason) {}
+
+ @Override
+ public void onSearchStopped(int reason) {}
+
+ @Override
+ public void onSearchStopFailed(int reason) {}
+
+ @Override
+ public void onSourceFound(@NonNull BluetoothLeBroadcastMetadata source) {}
+
+ @Override
+ public void onSourceAdded(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceAddFailed(
+ @NonNull BluetoothDevice sink,
+ @NonNull BluetoothLeBroadcastMetadata source,
+ int reason) {}
+
+ @Override
+ public void onSourceModified(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceModifyFailed(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceRemoved(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceRemoveFailed(
+ @NonNull BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onReceiveStateChanged(
+ @NonNull BluetoothDevice sink,
+ int sourceId,
+ @NonNull BluetoothLeBroadcastReceiveState state) {
+ if (BluetoothUtils.isConnected(state)) {
+ Log.d(TAG, "onReceiveStateChanged: synced, updateSummary");
+ updateSummary();
+ }
+ }
+ };
+
+ public CallsAndAlarmsPreferenceController(Context context) {
+ super(context, PREF_KEY);
+ mBtManager = Utils.getLocalBtManager(mContext);
+ mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
+ mEventManager = mBtManager == null ? null : mBtManager.getEventManager();
+ mAssistant =
+ mProfileManager == null
+ ? null
+ : mProfileManager.getLeAudioBroadcastAssistantProfile();
+ mExecutor = Executors.newSingleThreadExecutor();
+ mContentResolver = context.getContentResolver();
+ mSettingsObserver = new FallbackDeviceGroupIdSettingsObserver();
+ }
+
+ private class FallbackDeviceGroupIdSettingsObserver extends ContentObserver {
+ FallbackDeviceGroupIdSettingsObserver() {
+ super(new Handler(Looper.getMainLooper()));
+ }
+
+ @Override
+ public void onChange(boolean selfChange) {
+ Log.d(TAG, "onChange, fallback device group id has been changed");
+ var unused = ThreadUtils.postOnBackgroundThread(() -> updateSummary());
+ }
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return PREF_KEY;
+ }
+
+ @Override
+ public void displayPreference(@NonNull PreferenceScreen screen) {
+ super.displayPreference(screen);
+ if (mPreference != null) {
+ mPreference.setVisible(false);
+ updateSummary();
+ mPreference.setOnPreferenceClickListener(
+ preference -> {
+ if (mFragment == null) {
+ Log.w(TAG, "Dialog fail to show due to null host.");
+ return true;
+ }
+ updateDeviceItemsInSharingSession();
+ if (mDeviceItemsInSharingSession.size() >= 1) {
+ CallsAndAlarmsDialogFragment.show(
+ mFragment,
+ mDeviceItemsInSharingSession,
+ (AudioSharingDeviceItem item) -> {
+ if (!mGroupedConnectedDevices.containsKey(
+ item.getGroupId())) {
+ return;
+ }
+ List<CachedBluetoothDevice> devices =
+ mGroupedConnectedDevices.get(item.getGroupId());
+ @Nullable
+ CachedBluetoothDevice lead =
+ AudioSharingUtils.getLeadDevice(devices);
+ if (lead != null) {
+ Log.d(
+ TAG,
+ "Set fallback active device: "
+ + lead.getDevice()
+ .getAnonymizedAddress());
+ lead.setActive();
+ } else {
+ Log.w(
+ TAG,
+ "Fail to set fallback active device: no lead"
+ + " device");
+ }
+ });
+ }
+ return true;
+ });
+ }
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ super.onStart(owner);
+ registerCallbacks();
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ super.onStop(owner);
+ unregisterCallbacks();
+ }
+
+ @Override
+ public void onProfileConnectionStateChanged(
+ @NonNull CachedBluetoothDevice cachedDevice,
+ @ConnectionState int state,
+ int bluetoothProfile) {
+ if (state == BluetoothAdapter.STATE_DISCONNECTED
+ && bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) {
+ Log.d(TAG, "updatePreference, LE_AUDIO_BROADCAST_ASSISTANT is disconnected.");
+ // The fallback active device could be updated if the previous fallback device is
+ // disconnected.
+ updateSummary();
+ }
+ }
+
+ /**
+ * Initialize the controller.
+ *
+ * @param fragment The fragment to host the {@link CallsAndAlarmsDialogFragment} dialog.
+ */
+ public void init(DashboardFragment fragment) {
+ this.mFragment = fragment;
+ }
+
+ @VisibleForTesting
+ ContentObserver getSettingsObserver() {
+ return mSettingsObserver;
+ }
+
+ /** Test only: set callback registration status in tests. */
+ @VisibleForTesting
+ public void setCallbacksRegistered(boolean registered) {
+ mCallbacksRegistered.set(registered);
+ }
+
+ private void registerCallbacks() {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip registerCallbacks(). Feature is not available.");
+ return;
+ }
+ if (mEventManager == null || mContentResolver == null || mAssistant == null) {
+ Log.d(
+ TAG,
+ "Skip registerCallbacks(). Init is not ready: eventManager = "
+ + (mEventManager == null)
+ + ", contentResolver"
+ + (mContentResolver == null));
+ return;
+ }
+ if (!mCallbacksRegistered.get()) {
+ Log.d(TAG, "registerCallbacks()");
+ mEventManager.registerCallback(this);
+ mContentResolver.registerContentObserver(
+ Settings.Secure.getUriFor(SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID),
+ false,
+ mSettingsObserver);
+ mAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
+ mCallbacksRegistered.set(true);
+ }
+ }
+
+ private void unregisterCallbacks() {
+ if (!isAvailable()) {
+ Log.d(TAG, "Skip unregisterCallbacks(). Feature is not available.");
+ return;
+ }
+ if (mEventManager == null || mContentResolver == null || mAssistant == null) {
+ Log.d(TAG, "Skip unregisterCallbacks(). Init is not ready.");
+ return;
+ }
+ if (mCallbacksRegistered.get()) {
+ Log.d(TAG, "unregisterCallbacks()");
+ mEventManager.unregisterCallback(this);
+ mContentResolver.unregisterContentObserver(mSettingsObserver);
+ mAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
+ mCallbacksRegistered.set(false);
+ }
+ }
+
+ /**
+ * Update the preference summary: current headset for call audio.
+ *
+ * <p>The summary should be updated when:
+ *
+ * <p>1. displayPreference.
+ *
+ * <p>2. ContentObserver#onChange: the fallback device value in SettingsProvider is changed.
+ *
+ * <p>3. onProfileConnectionStateChanged: the assistant profile of fallback device disconnected.
+ * When the last headset in audio sharing disconnected, both Settings and bluetooth framework
+ * won't set the SettingsProvider, so no ContentObserver#onChange.
+ *
+ * <p>4. onReceiveStateChanged: new headset join the audio sharing. If the headset has already
+ * been set as fallback device in SettingsProvider by bluetooth framework when the broadcast is
+ * started, Settings won't set the SettingsProvider again when the headset join the audio
+ * sharing, so there won't be ContentObserver#onChange. We need listen to onReceiveStateChanged
+ * to handle this scenario.
+ */
+ private void updateSummary() {
+ updateDeviceItemsInSharingSession();
+ int fallbackActiveGroupId = AudioSharingUtils.getFallbackActiveGroupId(mContext);
+ if (fallbackActiveGroupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID) {
+ for (AudioSharingDeviceItem item : mDeviceItemsInSharingSession) {
+ if (item.getGroupId() == fallbackActiveGroupId) {
+ Log.d(
+ TAG,
+ "updatePreference: set summary tp fallback group "
+ + fallbackActiveGroupId);
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mPreference != null) {
+ mPreference.setSummary(
+ mContext.getString(
+ R.string.audio_sharing_call_audio_description,
+ item.getName()));
+ }
+ });
+ return;
+ }
+ }
+ }
+ Log.d(TAG, "updatePreference: set empty summary");
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mPreference != null) {
+ mPreference.setSummary("");
+ }
+ });
+ }
+
+ private void updateDeviceItemsInSharingSession() {
+ mGroupedConnectedDevices = AudioSharingUtils.fetchConnectedDevicesByGroupId(mBtManager);
+ mDeviceItemsInSharingSession =
+ AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem(
+ mBtManager, mGroupedConnectedDevices, /* filterByInSharing= */ true);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/StreamSettingsCategoryController.java b/src/com/android/settings/connecteddevice/audiosharing/StreamSettingsCategoryController.java
new file mode 100644
index 0000000..e9953a5
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/StreamSettingsCategoryController.java
@@ -0,0 +1,144 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import android.bluetooth.BluetoothAdapter;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+
+public class StreamSettingsCategoryController extends BasePreferenceController
+ implements DefaultLifecycleObserver, LocalBluetoothProfileManager.ServiceListener {
+ private static final String TAG = "StreamSettingsCategoryController";
+ private final BluetoothAdapter mBluetoothAdapter;
+ @Nullable private final LocalBluetoothManager mBtManager;
+ @Nullable private final LocalBluetoothProfileManager mProfileManager;
+ @Nullable private Preference mPreference;
+ @VisibleForTesting final IntentFilter mIntentFilter;
+
+ @VisibleForTesting
+ BroadcastReceiver mReceiver =
+ new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (!BluetoothAdapter.ACTION_STATE_CHANGED.equals(intent.getAction())) return;
+ updateVisibility();
+ }
+ };
+
+ public StreamSettingsCategoryController(Context context, String key) {
+ super(context, key);
+ mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
+ mBtManager = Utils.getLocalBtManager(context);
+ mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
+ mIntentFilter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED);
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) return;
+ mContext.registerReceiver(mReceiver, mIntentFilter, Context.RECEIVER_EXPORTED_UNAUDITED);
+ if (!isProfileReady() && mProfileManager != null) {
+ mProfileManager.addServiceListener(this);
+ }
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ if (!isAvailable()) return;
+ mContext.unregisterReceiver(mReceiver);
+ if (mProfileManager != null) {
+ mProfileManager.removeServiceListener(this);
+ }
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mPreference = screen.findPreference(getPreferenceKey());
+ updateVisibility();
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
+ }
+
+ @Override
+ public void onServiceConnected() {
+ if (isAvailable() && isProfileReady()) {
+ updateVisibility();
+ if (mProfileManager != null) {
+ mProfileManager.removeServiceListener(this);
+ }
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected() {
+ // Do nothing
+ }
+
+ private void updateVisibility() {
+ if (mPreference == null) {
+ Log.w(TAG, "Skip updateVisibility, null preference");
+ return;
+ }
+ if (!isAvailable()) {
+ Log.w(TAG, "Skip updateVisibility, unavailable preference");
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> { // Check nullability to pass NullAway check
+ if (mPreference != null) {
+ mPreference.setVisible(false);
+ }
+ });
+ return;
+ }
+ boolean visible = isBluetoothOn() && isProfileReady();
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> { // Check nullability to pass NullAway check
+ if (mPreference != null) {
+ mPreference.setVisible(visible);
+ }
+ });
+ }
+
+ private boolean isBluetoothOn() {
+ return mBluetoothAdapter != null && mBluetoothAdapter.isEnabled();
+ }
+
+ private boolean isProfileReady() {
+ return AudioSharingUtils.isAudioSharingProfileReady(mProfileManager);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceBadCodeState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceBadCodeState.java
new file mode 100644
index 0000000..1993377
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceBadCodeState.java
@@ -0,0 +1,49 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.settings.R;
+
+class AddSourceBadCodeState extends SyncedState {
+ @VisibleForTesting
+ static final int AUDIO_STREAM_ADD_SOURCE_BAD_CODE_STATE_SUMMARY =
+ R.string.audio_streams_add_source_bad_code_state_summary;
+
+ @Nullable private static AddSourceBadCodeState sInstance = null;
+
+ AddSourceBadCodeState() {}
+
+ static AddSourceBadCodeState getInstance() {
+ if (sInstance == null) {
+ sInstance = new AddSourceBadCodeState();
+ }
+ return sInstance;
+ }
+
+ @Override
+ int getSummary() {
+ return AUDIO_STREAM_ADD_SOURCE_BAD_CODE_STATE_SUMMARY;
+ }
+
+ @Override
+ AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
+ return AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_BAD_CODE;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceFailedState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceFailedState.java
new file mode 100644
index 0000000..5d151ee
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceFailedState.java
@@ -0,0 +1,49 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.settings.R;
+
+class AddSourceFailedState extends SyncedState {
+ @VisibleForTesting
+ static final int AUDIO_STREAM_ADD_SOURCE_FAILED_STATE_SUMMARY =
+ R.string.audio_streams_add_source_failed_state_summary;
+
+ @Nullable private static AddSourceFailedState sInstance = null;
+
+ AddSourceFailedState() {}
+
+ static AddSourceFailedState getInstance() {
+ if (sInstance == null) {
+ sInstance = new AddSourceFailedState();
+ }
+ return sInstance;
+ }
+
+ @Override
+ int getSummary() {
+ return AUDIO_STREAM_ADD_SOURCE_FAILED_STATE_SUMMARY;
+ }
+
+ @Override
+ AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
+ return AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_FAILED;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseState.java
new file mode 100644
index 0000000..4d6a7f9
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseState.java
@@ -0,0 +1,106 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.app.AlertDialog;
+import android.content.Context;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.settings.R;
+import com.android.settingslib.utils.ThreadUtils;
+
+class AddSourceWaitForResponseState extends AudioStreamStateHandler {
+ @VisibleForTesting
+ static final int AUDIO_STREAM_ADD_SOURCE_WAIT_FOR_RESPONSE_STATE_SUMMARY =
+ R.string.audio_streams_add_source_wait_for_response_summary;
+
+ @VisibleForTesting static final int ADD_SOURCE_WAIT_FOR_RESPONSE_TIMEOUT_MILLIS = 20000;
+
+ @Nullable private static AddSourceWaitForResponseState sInstance = null;
+
+ private AddSourceWaitForResponseState() {}
+
+ static AddSourceWaitForResponseState getInstance() {
+ if (sInstance == null) {
+ sInstance = new AddSourceWaitForResponseState();
+ }
+ return sInstance;
+ }
+
+ @Override
+ void performAction(
+ AudioStreamPreference preference,
+ AudioStreamsProgressCategoryController controller,
+ AudioStreamsHelper helper) {
+ mHandler.removeCallbacksAndMessages(preference);
+ var metadata = preference.getAudioStreamMetadata();
+ if (metadata != null) {
+ helper.addSource(metadata);
+ // Cache the metadata that used for add source, if source is added successfully, we
+ // will save it persistently.
+ mAudioStreamsRepository.cacheMetadata(metadata);
+
+ // It's possible that onSourceLost() is not notified even if the source is no longer
+ // valid. When calling addSource() for a source that's already lost, no callback
+ // will be sent back. So we remove the preference and pop up a dialog if it's state
+ // has not been changed after waiting for a certain time.
+ mHandler.postDelayed(
+ () -> {
+ if (preference.isShown()
+ && preference.getAudioStreamState() == getStateEnum()) {
+ controller.handleSourceFailedToConnect(
+ preference.getAudioStreamBroadcastId());
+ ThreadUtils.postOnMainThread(
+ () -> {
+ if (controller.getFragment() != null) {
+ AudioStreamsDialogFragment.show(
+ controller.getFragment(),
+ getBroadcastUnavailableNoRetryDialog(
+ preference.getContext(),
+ AudioStreamsHelper.getBroadcastName(
+ metadata)));
+ }
+ });
+ }
+ },
+ preference,
+ ADD_SOURCE_WAIT_FOR_RESPONSE_TIMEOUT_MILLIS);
+ }
+ }
+
+ @Override
+ int getSummary() {
+ return AUDIO_STREAM_ADD_SOURCE_WAIT_FOR_RESPONSE_STATE_SUMMARY;
+ }
+
+ @Override
+ AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
+ return AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE;
+ }
+
+ private AudioStreamsDialogFragment.DialogBuilder getBroadcastUnavailableNoRetryDialog(
+ Context context, String broadcastName) {
+ return new AudioStreamsDialogFragment.DialogBuilder(context)
+ .setTitle(context.getString(R.string.audio_streams_dialog_stream_is_not_available))
+ .setSubTitle1(broadcastName)
+ .setSubTitle2(context.getString(R.string.audio_streams_is_not_playing))
+ .setRightButtonText(context.getString(R.string.audio_streams_dialog_close))
+ .setRightButtonOnClickListener(AlertDialog::dismiss);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonController.java
new file mode 100644
index 0000000..ea5abdf
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonController.java
@@ -0,0 +1,197 @@
+/*
+ * 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.audiostreams;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.content.Context;
+import android.util.Log;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.utils.ThreadUtils;
+import com.android.settingslib.widget.ActionButtonsPreference;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+public class AudioStreamButtonController extends BasePreferenceController
+ implements DefaultLifecycleObserver {
+ private static final String TAG = "AudioStreamButtonController";
+ private static final String KEY = "audio_stream_button";
+ private final BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
+ new AudioStreamsBroadcastAssistantCallback() {
+ @Override
+ public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
+ super.onSourceRemoved(sink, sourceId, reason);
+ updateButton();
+ }
+
+ @Override
+ public void onSourceRemoveFailed(BluetoothDevice sink, int sourceId, int reason) {
+ super.onSourceRemoveFailed(sink, sourceId, reason);
+ updateButton();
+ }
+
+ @Override
+ public void onReceiveStateChanged(
+ BluetoothDevice sink,
+ int sourceId,
+ BluetoothLeBroadcastReceiveState state) {
+ super.onReceiveStateChanged(sink, sourceId, state);
+ if (AudioStreamsHelper.isConnected(state)) {
+ updateButton();
+ }
+ }
+
+ @Override
+ public void onSourceAddFailed(
+ BluetoothDevice sink, BluetoothLeBroadcastMetadata source, int reason) {
+ super.onSourceAddFailed(sink, source, reason);
+ updateButton();
+ }
+
+ @Override
+ public void onSourceLost(int broadcastId) {
+ super.onSourceLost(broadcastId);
+ updateButton();
+ }
+ };
+
+ private final AudioStreamsRepository mAudioStreamsRepository =
+ AudioStreamsRepository.getInstance();
+ private final Executor mExecutor;
+ private final AudioStreamsHelper mAudioStreamsHelper;
+ private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
+ private @Nullable ActionButtonsPreference mPreference;
+ private int mBroadcastId = -1;
+
+ public AudioStreamButtonController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ mExecutor = Executors.newSingleThreadExecutor();
+ mAudioStreamsHelper = new AudioStreamsHelper(Utils.getLocalBtManager(context));
+ mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ if (mLeBroadcastAssistant == null) {
+ Log.w(TAG, "onStart(): LeBroadcastAssistant is null!");
+ return;
+ }
+ mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ if (mLeBroadcastAssistant == null) {
+ Log.w(TAG, "onStop(): LeBroadcastAssistant is null!");
+ return;
+ }
+ mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
+ }
+
+ @Override
+ public final void displayPreference(PreferenceScreen screen) {
+ mPreference = screen.findPreference(getPreferenceKey());
+ updateButton();
+ super.displayPreference(screen);
+ }
+
+ private void updateButton() {
+ if (mPreference != null) {
+ if (mAudioStreamsHelper.getAllConnectedSources().stream()
+ .map(BluetoothLeBroadcastReceiveState::getBroadcastId)
+ .anyMatch(connectedBroadcastId -> connectedBroadcastId == mBroadcastId)) {
+ ThreadUtils.postOnMainThread(
+ () -> {
+ if (mPreference != null) {
+ mPreference.setButton1Enabled(true);
+ mPreference
+ .setButton1Text(R.string.audio_streams_disconnect)
+ .setButton1Icon(
+ com.android.settings.R.drawable.ic_settings_close)
+ .setButton1OnClickListener(
+ unused -> {
+ if (mPreference != null) {
+ mPreference.setButton1Enabled(false);
+ }
+ mAudioStreamsHelper.removeSource(mBroadcastId);
+ });
+ }
+ });
+ } else {
+ View.OnClickListener clickToRejoin =
+ unused ->
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ var metadata =
+ mAudioStreamsRepository.getSavedMetadata(
+ mContext, mBroadcastId);
+ if (metadata != null) {
+ mAudioStreamsHelper.addSource(metadata);
+ ThreadUtils.postOnMainThread(
+ () -> {
+ if (mPreference != null) {
+ mPreference.setButton1Enabled(
+ false);
+ }
+ });
+ }
+ });
+ ThreadUtils.postOnMainThread(
+ () -> {
+ if (mPreference != null) {
+ mPreference.setButton1Enabled(true);
+ mPreference
+ .setButton1Text(R.string.audio_streams_connect)
+ .setButton1Icon(com.android.settings.R.drawable.ic_add_24dp)
+ .setButton1OnClickListener(clickToRejoin);
+ }
+ });
+ }
+ } else {
+ Log.w(TAG, "updateButton(): preference is null!");
+ }
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AVAILABLE;
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return KEY;
+ }
+
+ /** Initialize with broadcast id */
+ void init(int broadcastId) {
+ mBroadcastId = broadcastId;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialog.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialog.java
new file mode 100644
index 0000000..8e27958
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialog.java
@@ -0,0 +1,195 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.settings.SettingsEnums;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.content.Intent;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
+import com.android.settings.connecteddevice.audiosharing.audiostreams.qrcode.QrCodeScanModeFragment;
+import com.android.settings.core.SubSettingLauncher;
+import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
+import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+
+import com.google.common.base.Strings;
+
+public class AudioStreamConfirmDialog extends InstrumentedDialogFragment {
+ public static final String KEY_BROADCAST_METADATA = "key_broadcast_metadata";
+ private static final String TAG = "AudioStreamConfirmDialog";
+ private static final int DEFAULT_DEVICE_NAME = R.string.audio_streams_dialog_default_device;
+ @Nullable private LocalBluetoothManager mLocalBluetoothManager;
+ @Nullable private LocalBluetoothProfileManager mProfileManager;
+ @Nullable private Activity mActivity;
+ @Nullable private String mBroadcastMetadataStr;
+ @Nullable private BluetoothLeBroadcastMetadata mBroadcastMetadata;
+ private boolean mIsRequestValid = false;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (!AudioSharingUtils.isFeatureEnabled()) {
+ return;
+ }
+ setShowsDialog(true);
+ mActivity = getActivity();
+ if (mActivity == null) {
+ Log.w(TAG, "onCreate() mActivity is null!");
+ return;
+ }
+ mLocalBluetoothManager = Utils.getLocalBluetoothManager(mActivity);
+ mProfileManager =
+ mLocalBluetoothManager == null ? null : mLocalBluetoothManager.getProfileManager();
+ mBroadcastMetadataStr =
+ mActivity.getIntent().getStringExtra(QrCodeScanModeFragment.KEY_BROADCAST_METADATA);
+ if (Strings.isNullOrEmpty(mBroadcastMetadataStr)) {
+ Log.w(TAG, "onCreate() mBroadcastMetadataStr is null or empty!");
+ return;
+ }
+ mBroadcastMetadata =
+ BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(
+ mBroadcastMetadataStr);
+ if (mBroadcastMetadata == null) {
+ Log.w(TAG, "onCreate() mBroadcastMetadata is null!");
+ } else {
+ mIsRequestValid = true;
+ }
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
+ CachedBluetoothDevice connectedLeDevice =
+ AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(
+ mLocalBluetoothManager)
+ .orElse(null);
+ if (connectedLeDevice == null) {
+ return getNoLeDeviceDialog();
+ }
+ String deviceName = connectedLeDevice.getName();
+ return mIsRequestValid ? getConfirmDialog(deviceName) : getErrorDialog(deviceName);
+ }
+ Log.d(TAG, "onCreateDialog() : profile not ready!");
+ String defaultDeviceName =
+ mActivity != null ? mActivity.getString(DEFAULT_DEVICE_NAME) : "";
+ return mIsRequestValid
+ ? getConfirmDialog(defaultDeviceName)
+ : getErrorDialog(defaultDeviceName);
+ }
+
+ @Override
+ public int getMetricsCategory() {
+ // TODO(chelseahao): update metrics id
+ return 0;
+ }
+
+ private Dialog getConfirmDialog(String name) {
+ return new AudioStreamsDialogFragment.DialogBuilder(getActivity())
+ .setTitle(getString(R.string.audio_streams_dialog_listen_to_audio_stream))
+ .setSubTitle1(
+ mBroadcastMetadata != null
+ ? AudioStreamsHelper.getBroadcastName(mBroadcastMetadata)
+ : "")
+ .setSubTitle2(getString(R.string.audio_streams_dialog_control_volume, name))
+ .setLeftButtonText(getString(com.android.settings.R.string.cancel))
+ .setLeftButtonOnClickListener(
+ unused -> {
+ dismiss();
+ if (mActivity != null) {
+ mActivity.finish();
+ }
+ })
+ .setRightButtonText(getString(R.string.audio_streams_dialog_listen))
+ .setRightButtonOnClickListener(
+ unused -> {
+ launchAudioStreamsActivity();
+ dismiss();
+ if (mActivity != null) {
+ mActivity.finish();
+ }
+ })
+ .build();
+ }
+
+ private Dialog getErrorDialog(String name) {
+ return new AudioStreamsDialogFragment.DialogBuilder(getActivity())
+ .setTitle(getString(R.string.audio_streams_dialog_cannot_listen))
+ .setSubTitle2(getString(R.string.audio_streams_dialog_cannot_play, name))
+ .setRightButtonText(getString(R.string.audio_streams_dialog_close))
+ .setRightButtonOnClickListener(
+ unused -> {
+ dismiss();
+ if (mActivity != null) {
+ mActivity.finish();
+ }
+ })
+ .build();
+ }
+
+ private Dialog getNoLeDeviceDialog() {
+ return new AudioStreamsDialogFragment.DialogBuilder(getActivity())
+ .setTitle(getString(R.string.audio_streams_dialog_no_le_device_title))
+ .setSubTitle2(getString(R.string.audio_streams_dialog_no_le_device_subtitle))
+ .setLeftButtonText(getString(R.string.audio_streams_dialog_close))
+ .setLeftButtonOnClickListener(
+ unused -> {
+ dismiss();
+ if (mActivity != null) {
+ mActivity.finish();
+ }
+ })
+ .setRightButtonText(getString(R.string.audio_streams_dialog_no_le_device_button))
+ .setRightButtonOnClickListener(
+ dialog -> {
+ if (mActivity != null) {
+ mActivity.startActivity(
+ new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
+ .setPackage(mActivity.getPackageName()));
+ }
+ dismiss();
+ if (mActivity != null) {
+ mActivity.finish();
+ }
+ })
+ .build();
+ }
+
+ private void launchAudioStreamsActivity() {
+ Bundle bundle = new Bundle();
+ bundle.putString(KEY_BROADCAST_METADATA, mBroadcastMetadataStr);
+ if (mActivity != null) {
+ new SubSettingLauncher(getActivity())
+ .setTitleText(getString(R.string.audio_streams_activity_title))
+ .setDestination(AudioStreamsDashboardFragment.class.getName())
+ .setArguments(bundle)
+ .setSourceMetricsCategory(SettingsEnums.PAGE_UNKNOWN)
+ .launch();
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogActivity.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogActivity.java
new file mode 100644
index 0000000..695ad93
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogActivity.java
@@ -0,0 +1,38 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.os.Bundle;
+
+import com.android.settings.SettingsActivity;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
+
+public class AudioStreamConfirmDialogActivity extends SettingsActivity {
+
+ @Override
+ protected void onCreate(Bundle savedState) {
+ super.onCreate(savedState);
+ if (!AudioSharingUtils.isFeatureEnabled()) {
+ finish();
+ }
+ }
+
+ @Override
+ protected boolean isValidFragment(String fragmentName) {
+ return AudioStreamConfirmDialog.class.getName().equals(fragmentName);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamDetailsFragment.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamDetailsFragment.java
new file mode 100644
index 0000000..94e6644
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamDetailsFragment.java
@@ -0,0 +1,59 @@
+/*
+ * 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.audiostreams;
+
+import android.content.Context;
+import android.os.Bundle;
+
+import com.android.settings.R;
+import com.android.settings.dashboard.DashboardFragment;
+
+public class AudioStreamDetailsFragment extends DashboardFragment {
+ static final String BROADCAST_NAME_ARG = "broadcast_name";
+ static final String BROADCAST_ID_ARG = "broadcast_id";
+ private static final String TAG = "AudioStreamDetailsFragment";
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ Bundle arguments = getArguments();
+ if (arguments != null) {
+ use(AudioStreamHeaderController.class)
+ .init(
+ this,
+ arguments.getString(BROADCAST_NAME_ARG),
+ arguments.getInt(BROADCAST_ID_ARG));
+ use(AudioStreamButtonController.class).init(arguments.getInt(BROADCAST_ID_ARG));
+ }
+ }
+
+ @Override
+ public int getMetricsCategory() {
+ // TODO(chelseahao): update metrics id
+ return 0;
+ }
+
+ @Override
+ protected int getPreferenceScreenResId() {
+ return R.xml.bluetooth_le_audio_stream_details_fragment;
+ }
+
+ @Override
+ protected String getLogTag() {
+ return TAG;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderController.java
new file mode 100644
index 0000000..860e62e
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderController.java
@@ -0,0 +1,182 @@
+/*
+ * 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.audiostreams;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settings.widget.EntityHeaderController;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.utils.ThreadUtils;
+import com.android.settingslib.widget.LayoutPreference;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import javax.annotation.Nullable;
+
+public class AudioStreamHeaderController extends BasePreferenceController
+ implements DefaultLifecycleObserver {
+ @VisibleForTesting
+ static final int AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY =
+ R.string.audio_streams_listening_now;
+
+ @VisibleForTesting static final String AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY = "";
+ private static final String TAG = "AudioStreamHeaderController";
+ private static final String KEY = "audio_stream_header";
+ private final Executor mExecutor;
+ private final AudioStreamsHelper mAudioStreamsHelper;
+ @Nullable private final LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
+ private final BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
+ new AudioStreamsBroadcastAssistantCallback() {
+ @Override
+ public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
+ super.onSourceRemoved(sink, sourceId, reason);
+ updateSummary();
+ }
+
+ @Override
+ public void onSourceLost(int broadcastId) {
+ super.onSourceLost(broadcastId);
+ updateSummary();
+ }
+
+ @Override
+ public void onReceiveStateChanged(
+ BluetoothDevice sink,
+ int sourceId,
+ BluetoothLeBroadcastReceiveState state) {
+ super.onReceiveStateChanged(sink, sourceId, state);
+ if (AudioStreamsHelper.isConnected(state)) {
+ updateSummary();
+ mAudioStreamsHelper.startMediaService(
+ mContext, mBroadcastId, mBroadcastName);
+ }
+ }
+ };
+
+ private @Nullable EntityHeaderController mHeaderController;
+ private @Nullable DashboardFragment mFragment;
+ private String mBroadcastName = "";
+ private int mBroadcastId = -1;
+
+ public AudioStreamHeaderController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ mExecutor = Executors.newSingleThreadExecutor();
+ mAudioStreamsHelper = new AudioStreamsHelper(Utils.getLocalBtManager(context));
+ mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ if (mLeBroadcastAssistant == null) {
+ Log.w(TAG, "onStart(): LeBroadcastAssistant is null!");
+ return;
+ }
+ mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ if (mLeBroadcastAssistant == null) {
+ Log.w(TAG, "onStop(): LeBroadcastAssistant is null!");
+ return;
+ }
+ mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
+ }
+
+ @Override
+ public final void displayPreference(PreferenceScreen screen) {
+ LayoutPreference headerPreference = screen.findPreference(KEY);
+ if (headerPreference != null && mFragment != null) {
+ mHeaderController =
+ EntityHeaderController.newInstance(
+ mFragment.getActivity(),
+ mFragment,
+ headerPreference.findViewById(com.android.settings.R.id.entity_header));
+ if (mBroadcastName != null) {
+ mHeaderController.setLabel(mBroadcastName);
+ }
+ mHeaderController.setIcon(
+ screen.getContext()
+ .getDrawable(
+ com.android.settingslib.R.drawable.ic_bt_le_audio_sharing));
+ screen.addPreference(headerPreference);
+ updateSummary();
+ }
+ super.displayPreference(screen);
+ }
+
+ private void updateSummary() {
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ var latestSummary =
+ mAudioStreamsHelper.getAllConnectedSources().stream()
+ .map(
+ BluetoothLeBroadcastReceiveState
+ ::getBroadcastId)
+ .anyMatch(
+ connectedBroadcastId ->
+ connectedBroadcastId
+ == mBroadcastId)
+ ? mContext.getString(
+ AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY)
+ : AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY;
+ ThreadUtils.postOnMainThread(
+ () -> {
+ if (mHeaderController != null) {
+ mHeaderController.setSummary(latestSummary);
+ mHeaderController.done(true);
+ }
+ });
+ });
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AVAILABLE;
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return KEY;
+ }
+
+ /** Initialize with {@link AudioStreamDetailsFragment} and broadcast name and id */
+ void init(
+ AudioStreamDetailsFragment audioStreamDetailsFragment,
+ String broadcastName,
+ int broadcastId) {
+ mFragment = audioStreamDetailsFragment;
+ mBroadcastName = broadcastName;
+ mBroadcastId = broadcastId;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaService.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaService.java
new file mode 100644
index 0000000..2e4930c
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaService.java
@@ -0,0 +1,378 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothVolumeControl;
+import android.content.Intent;
+import android.media.MediaMetadata;
+import android.media.session.MediaSession;
+import android.media.session.PlaybackState;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.util.Log;
+
+import androidx.annotation.IntRange;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+
+import java.util.ArrayList;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class AudioStreamMediaService extends Service {
+ static final String BROADCAST_ID = "audio_stream_media_service_broadcast_id";
+ static final String BROADCAST_TITLE = "audio_stream_media_service_broadcast_title";
+ static final String DEVICES = "audio_stream_media_service_devices";
+ private static final String TAG = "AudioStreamMediaService";
+ private static final int NOTIFICATION_ID = 1;
+ private static final int BROADCAST_CONTENT_TEXT = R.string.audio_streams_listening_now;
+ private static final String LEAVE_BROADCAST_ACTION = "leave_broadcast_action";
+ private static final String LEAVE_BROADCAST_TEXT = "Leave Broadcast";
+ private static final String CHANNEL_ID = "bluetooth_notification_channel";
+ private static final int STATIC_PLAYBACK_DURATION = 100;
+ private static final int STATIC_PLAYBACK_POSITION = 30;
+ private static final int ZERO_PLAYBACK_SPEED = 0;
+ private final AudioStreamsBroadcastAssistantCallback mBroadcastAssistantCallback =
+ new AudioStreamsBroadcastAssistantCallback() {
+ @Override
+ public void onSourceLost(int broadcastId) {
+ super.onSourceLost(broadcastId);
+ if (broadcastId == mBroadcastId) {
+ stopSelf();
+ }
+ }
+
+ @Override
+ public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
+ super.onSourceRemoved(sink, sourceId, reason);
+ if (mAudioStreamsHelper != null
+ && mAudioStreamsHelper.getAllConnectedSources().stream()
+ .map(BluetoothLeBroadcastReceiveState::getBroadcastId)
+ .noneMatch(id -> id == mBroadcastId)) {
+ stopSelf();
+ }
+ }
+ };
+
+ private final BluetoothCallback mBluetoothCallback =
+ new BluetoothCallback() {
+ @Override
+ public void onBluetoothStateChanged(int bluetoothState) {
+ if (BluetoothAdapter.STATE_OFF == bluetoothState) {
+ stopSelf();
+ }
+ }
+
+ @Override
+ public void onProfileConnectionStateChanged(
+ @NonNull CachedBluetoothDevice cachedDevice,
+ @ConnectionState int state,
+ int bluetoothProfile) {
+ if (state == BluetoothAdapter.STATE_DISCONNECTED
+ && bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT
+ && mDevices != null) {
+ mDevices.remove(cachedDevice.getDevice());
+ cachedDevice
+ .getMemberDevice()
+ .forEach(
+ m -> {
+ // Check nullability to pass NullAway check
+ if (mDevices != null) {
+ mDevices.remove(m.getDevice());
+ }
+ });
+ }
+ if (mDevices == null || mDevices.isEmpty()) {
+ stopSelf();
+ }
+ }
+ };
+
+ private final BluetoothVolumeControl.Callback mVolumeControlCallback =
+ new BluetoothVolumeControl.Callback() {
+ @Override
+ public void onDeviceVolumeChanged(
+ @NonNull BluetoothDevice device,
+ @IntRange(from = -255, to = 255) int volume) {
+ if (mDevices == null || mDevices.isEmpty()) {
+ Log.w(TAG, "active device or device has source is null!");
+ return;
+ }
+ if (mDevices.contains(device)) {
+ Log.d(
+ TAG,
+ "onDeviceVolumeChanged() bluetoothDevice : "
+ + device
+ + " volume: "
+ + volume);
+ if (volume == 0) {
+ mIsMuted = true;
+ } else {
+ mIsMuted = false;
+ mLatestPositiveVolume = volume;
+ }
+ if (mLocalSession != null) {
+ mLocalSession.setPlaybackState(getPlaybackState());
+ if (mNotificationManager != null) {
+ mNotificationManager.notify(NOTIFICATION_ID, buildNotification());
+ }
+ }
+ }
+ }
+ };
+
+ private final PlaybackState.Builder mPlayStatePlayingBuilder =
+ new PlaybackState.Builder()
+ .setActions(PlaybackState.ACTION_PAUSE | PlaybackState.ACTION_SEEK_TO)
+ .setState(
+ PlaybackState.STATE_PLAYING,
+ STATIC_PLAYBACK_POSITION,
+ ZERO_PLAYBACK_SPEED)
+ .addCustomAction(
+ LEAVE_BROADCAST_ACTION,
+ LEAVE_BROADCAST_TEXT,
+ com.android.settings.R.drawable.ic_clear);
+ private final PlaybackState.Builder mPlayStatePausingBuilder =
+ new PlaybackState.Builder()
+ .setActions(PlaybackState.ACTION_PLAY | PlaybackState.ACTION_SEEK_TO)
+ .setState(
+ PlaybackState.STATE_PAUSED,
+ STATIC_PLAYBACK_POSITION,
+ ZERO_PLAYBACK_SPEED)
+ .addCustomAction(
+ LEAVE_BROADCAST_ACTION,
+ LEAVE_BROADCAST_TEXT,
+ com.android.settings.R.drawable.ic_clear);
+
+ private final ExecutorService mExecutor = Executors.newSingleThreadExecutor();
+ private int mBroadcastId;
+ @Nullable private ArrayList<BluetoothDevice> mDevices;
+ @Nullable private LocalBluetoothManager mLocalBtManager;
+ @Nullable private AudioStreamsHelper mAudioStreamsHelper;
+ @Nullable private LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
+ @Nullable private VolumeControlProfile mVolumeControl;
+ @Nullable private NotificationManager mNotificationManager;
+
+ // Set 25 as default as the volume range from `VolumeControlProfile` is from 0 to 255.
+ // If the initial volume from `onDeviceVolumeChanged` is larger than zero (not muted), we will
+ // override this value. Otherwise, we raise the volume to 25 when the play button is clicked.
+ private int mLatestPositiveVolume = 25;
+ private boolean mIsMuted = false;
+ @Nullable private MediaSession mLocalSession;
+
+ @Override
+ public void onCreate() {
+ if (!AudioSharingUtils.isFeatureEnabled()) {
+ return;
+ }
+
+ super.onCreate();
+ mLocalBtManager = Utils.getLocalBtManager(this);
+ if (mLocalBtManager == null) {
+ Log.w(TAG, "onCreate() : mLocalBtManager is null!");
+ return;
+ }
+
+ mAudioStreamsHelper = new AudioStreamsHelper(mLocalBtManager);
+ mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
+ if (mLeBroadcastAssistant == null) {
+ Log.w(TAG, "onCreate() : mLeBroadcastAssistant is null!");
+ return;
+ }
+
+ mNotificationManager = getSystemService(NotificationManager.class);
+ if (mNotificationManager == null) {
+ Log.w(TAG, "onCreate() : notificationManager is null!");
+ return;
+ }
+
+ if (mNotificationManager.getNotificationChannel(CHANNEL_ID) == null) {
+ NotificationChannel notificationChannel =
+ new NotificationChannel(
+ CHANNEL_ID,
+ this.getString(com.android.settings.R.string.bluetooth),
+ NotificationManager.IMPORTANCE_HIGH);
+ mNotificationManager.createNotificationChannel(notificationChannel);
+ }
+
+ mLocalBtManager.getEventManager().registerCallback(mBluetoothCallback);
+
+ mVolumeControl = mLocalBtManager.getProfileManager().getVolumeControlProfile();
+ if (mVolumeControl != null) {
+ mVolumeControl.registerCallback(mExecutor, mVolumeControlCallback);
+ }
+
+ mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+
+ if (!AudioSharingUtils.isFeatureEnabled()) {
+ return;
+ }
+ if (mLocalBtManager != null) {
+ mLocalBtManager.getEventManager().unregisterCallback(mBluetoothCallback);
+ }
+ if (mLeBroadcastAssistant != null) {
+ mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
+ }
+ if (mVolumeControl != null) {
+ mVolumeControl.unregisterCallback(mVolumeControlCallback);
+ }
+ if (mLocalSession != null) {
+ mLocalSession.release();
+ mLocalSession = null;
+ }
+ }
+
+ @Override
+ public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
+ Log.d(TAG, "onStartCommand()");
+
+ mBroadcastId = intent != null ? intent.getIntExtra(BROADCAST_ID, -1) : -1;
+ if (mBroadcastId == -1) {
+ Log.w(TAG, "Invalid broadcast ID. Service will not start.");
+ stopSelf();
+ return START_NOT_STICKY;
+ }
+
+ if (intent != null) {
+ mDevices = intent.getParcelableArrayListExtra(DEVICES, BluetoothDevice.class);
+ }
+ if (mDevices == null || mDevices.isEmpty()) {
+ Log.w(TAG, "No device. Service will not start.");
+ stopSelf();
+ return START_NOT_STICKY;
+ }
+ if (intent != null) {
+ createLocalMediaSession(intent.getStringExtra(BROADCAST_TITLE));
+ startForeground(NOTIFICATION_ID, buildNotification());
+ }
+
+ return START_NOT_STICKY;
+ }
+
+ private void createLocalMediaSession(String title) {
+ mLocalSession = new MediaSession(this, TAG);
+ mLocalSession.setMetadata(
+ new MediaMetadata.Builder()
+ .putString(MediaMetadata.METADATA_KEY_TITLE, title)
+ .putLong(MediaMetadata.METADATA_KEY_DURATION, STATIC_PLAYBACK_DURATION)
+ .build());
+ mLocalSession.setActive(true);
+ mLocalSession.setPlaybackState(getPlaybackState());
+ mLocalSession.setCallback(
+ new MediaSession.Callback() {
+ public void onSeekTo(long pos) {
+ Log.d(TAG, "onSeekTo: " + pos);
+ if (mLocalSession != null) {
+ mLocalSession.setPlaybackState(getPlaybackState());
+ if (mNotificationManager != null) {
+ mNotificationManager.notify(NOTIFICATION_ID, buildNotification());
+ }
+ }
+ }
+
+ @Override
+ public void onPause() {
+ if (mDevices == null || mDevices.isEmpty()) {
+ Log.w(TAG, "active device or device has source is null!");
+ return;
+ }
+ Log.d(
+ TAG,
+ "onPause() setting volume for device : "
+ + mDevices.get(0)
+ + " volume: "
+ + 0);
+ if (mVolumeControl != null) {
+ mVolumeControl.setDeviceVolume(mDevices.get(0), 0, true);
+ }
+ }
+
+ @Override
+ public void onPlay() {
+ if (mDevices == null || mDevices.isEmpty()) {
+ Log.w(TAG, "active device or device has source is null!");
+ return;
+ }
+ Log.d(
+ TAG,
+ "onPlay() setting volume for device : "
+ + mDevices.get(0)
+ + " volume: "
+ + mLatestPositiveVolume);
+ if (mVolumeControl != null) {
+ mVolumeControl.setDeviceVolume(
+ mDevices.get(0), mLatestPositiveVolume, true);
+ }
+ }
+
+ @Override
+ public void onCustomAction(@NonNull String action, Bundle extras) {
+ Log.d(TAG, "onCustomAction: " + action);
+ if (action.equals(LEAVE_BROADCAST_ACTION) && mAudioStreamsHelper != null) {
+ mAudioStreamsHelper.removeSource(mBroadcastId);
+ }
+ }
+ });
+ }
+
+ private PlaybackState getPlaybackState() {
+ return mIsMuted ? mPlayStatePausingBuilder.build() : mPlayStatePlayingBuilder.build();
+ }
+
+ private Notification buildNotification() {
+ Notification.Builder notificationBuilder =
+ new Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
+ .setStyle(
+ new Notification.MediaStyle()
+ .setMediaSession(
+ mLocalSession != null
+ ? mLocalSession.getSessionToken()
+ : null))
+ .setContentText(this.getString(BROADCAST_CONTENT_TEXT))
+ .setSilent(true);
+ return notificationBuilder.build();
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamPreference.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamPreference.java
new file mode 100644
index 0000000..0334e05
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamPreference.java
@@ -0,0 +1,201 @@
+/*
+ * 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.audiostreams;
+
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.preference.PreferenceViewHolder;
+
+import com.android.settings.R;
+import com.android.settingslib.widget.TwoTargetPreference;
+
+/**
+ * Custom preference class for managing audio stream preferences with an optional lock icon. Extends
+ * {@link TwoTargetPreference}.
+ */
+class AudioStreamPreference extends TwoTargetPreference {
+ private boolean mIsConnected = false;
+ private boolean mIsEncrypted = true;
+ @Nullable private AudioStream mAudioStream;
+
+ /**
+ * Update preference UI based on connection status
+ *
+ * @param isConnected Is this stream connected
+ * @param summary Summary text
+ * @param onPreferenceClickListener Click listener for the preference
+ */
+ void setIsConnected(
+ boolean isConnected,
+ String summary,
+ @Nullable OnPreferenceClickListener onPreferenceClickListener) {
+ if (mIsConnected == isConnected
+ && getSummary() == summary
+ && getOnPreferenceClickListener() == onPreferenceClickListener) {
+ // Nothing to update.
+ return;
+ }
+ mIsConnected = isConnected;
+ setSummary(summary);
+ setOnPreferenceClickListener(onPreferenceClickListener);
+ notifyChanged();
+ }
+
+ private AudioStreamPreference(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ setIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing);
+ }
+
+ void setAudioStreamState(AudioStreamsProgressCategoryController.AudioStreamState state) {
+ if (mAudioStream != null) {
+ mAudioStream.setState(state);
+ }
+ }
+
+ void setAudioStreamMetadata(BluetoothLeBroadcastMetadata metadata) {
+ if (mAudioStream != null) {
+ mAudioStream.setMetadata(metadata);
+ }
+ }
+
+ int getAudioStreamBroadcastId() {
+ return mAudioStream != null ? mAudioStream.getBroadcastId() : -1;
+ }
+
+ @Nullable
+ String getAudioStreamBroadcastName() {
+ return mAudioStream != null ? mAudioStream.getBroadcastName() : null;
+ }
+
+ int getAudioStreamRssi() {
+ return mAudioStream != null ? mAudioStream.getRssi() : -1;
+ }
+
+ @Nullable
+ BluetoothLeBroadcastMetadata getAudioStreamMetadata() {
+ return mAudioStream != null ? mAudioStream.getMetadata() : null;
+ }
+
+ AudioStreamsProgressCategoryController.AudioStreamState getAudioStreamState() {
+ return mAudioStream != null
+ ? mAudioStream.getState()
+ : AudioStreamsProgressCategoryController.AudioStreamState.UNKNOWN;
+ }
+
+ @Override
+ protected boolean shouldHideSecondTarget() {
+ return mIsConnected || !mIsEncrypted;
+ }
+
+ @Override
+ protected int getSecondTargetResId() {
+ return R.layout.preference_widget_lock;
+ }
+
+ @Override
+ public void onBindViewHolder(PreferenceViewHolder holder) {
+ super.onBindViewHolder(holder);
+ View divider =
+ holder.findViewById(
+ com.android.settingslib.widget.preference.twotarget.R.id
+ .two_target_divider);
+ if (divider != null) {
+ divider.setVisibility(View.GONE);
+ }
+ }
+
+ static AudioStreamPreference fromMetadata(
+ Context context, BluetoothLeBroadcastMetadata source) {
+ AudioStreamPreference preference = new AudioStreamPreference(context, /* attrs= */ null);
+ preference.setIsEncrypted(source.isEncrypted());
+ preference.setTitle(AudioStreamsHelper.getBroadcastName(source));
+ preference.setAudioStream(new AudioStream(source));
+ return preference;
+ }
+
+ static AudioStreamPreference fromReceiveState(
+ Context context, BluetoothLeBroadcastReceiveState receiveState) {
+ AudioStreamPreference preference = new AudioStreamPreference(context, /* attrs= */ null);
+ preference.setTitle(AudioStreamsHelper.getBroadcastName(receiveState));
+ preference.setAudioStream(new AudioStream(receiveState));
+ return preference;
+ }
+
+ private void setAudioStream(AudioStream audioStream) {
+ mAudioStream = audioStream;
+ }
+
+ private void setIsEncrypted(boolean isEncrypted) {
+ mIsEncrypted = isEncrypted;
+ }
+
+ private static final class AudioStream {
+ private static final int UNAVAILABLE = -1;
+ @Nullable private BluetoothLeBroadcastMetadata mMetadata;
+ @Nullable private BluetoothLeBroadcastReceiveState mReceiveState;
+ private AudioStreamsProgressCategoryController.AudioStreamState mState =
+ AudioStreamsProgressCategoryController.AudioStreamState.UNKNOWN;
+
+ private AudioStream(BluetoothLeBroadcastMetadata metadata) {
+ mMetadata = metadata;
+ }
+
+ private AudioStream(BluetoothLeBroadcastReceiveState receiveState) {
+ mReceiveState = receiveState;
+ }
+
+ private int getBroadcastId() {
+ return mMetadata != null
+ ? mMetadata.getBroadcastId()
+ : mReceiveState != null ? mReceiveState.getBroadcastId() : UNAVAILABLE;
+ }
+
+ private @Nullable String getBroadcastName() {
+ return mMetadata != null
+ ? AudioStreamsHelper.getBroadcastName(mMetadata)
+ : mReceiveState != null
+ ? AudioStreamsHelper.getBroadcastName(mReceiveState)
+ : null;
+ }
+
+ private int getRssi() {
+ return mMetadata != null ? mMetadata.getRssi() : Integer.MAX_VALUE;
+ }
+
+ private AudioStreamsProgressCategoryController.AudioStreamState getState() {
+ return mState;
+ }
+
+ @Nullable
+ private BluetoothLeBroadcastMetadata getMetadata() {
+ return mMetadata;
+ }
+
+ private void setState(AudioStreamsProgressCategoryController.AudioStreamState state) {
+ mState = state;
+ }
+
+ private void setMetadata(BluetoothLeBroadcastMetadata metadata) {
+ mMetadata = metadata;
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java
new file mode 100644
index 0000000..df176be
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamStateHandler.java
@@ -0,0 +1,111 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.annotation.VisibleForTesting;
+import androidx.preference.Preference;
+
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.utils.ThreadUtils;
+
+class AudioStreamStateHandler {
+ private static final String TAG = "AudioStreamStateHandler";
+ private static final boolean DEBUG = BluetoothUtils.D;
+ @VisibleForTesting static final int EMPTY_STRING_RES = 0;
+
+ final AudioStreamsRepository mAudioStreamsRepository = AudioStreamsRepository.getInstance();
+ final Handler mHandler = new Handler(Looper.getMainLooper());
+
+ AudioStreamStateHandler() {}
+
+ void handleStateChange(
+ AudioStreamPreference preference,
+ AudioStreamsProgressCategoryController controller,
+ AudioStreamsHelper helper) {
+ var newState = getStateEnum();
+ if (preference.getAudioStreamState() == newState) {
+ return;
+ }
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "moveToState() : moving preference : ["
+ + preference.getAudioStreamBroadcastId()
+ + ", "
+ + preference.getAudioStreamBroadcastName()
+ + "] from state : "
+ + preference.getAudioStreamState()
+ + " to state : "
+ + newState);
+ }
+ preference.setAudioStreamState(newState);
+
+ performAction(preference, controller, helper);
+
+ // Update UI
+ ThreadUtils.postOnMainThread(
+ () ->
+ preference.setIsConnected(
+ newState
+ == AudioStreamsProgressCategoryController.AudioStreamState
+ .SOURCE_ADDED,
+ getSummary() != EMPTY_STRING_RES
+ ? preference.getContext().getString(getSummary())
+ : "",
+ getOnClickListener(controller)));
+ }
+
+ /**
+ * Perform action related to the audio stream state (e.g, addSource) This method is intended to
+ * be optionally overridden by subclasses to provide custom behavior based on the audio stream
+ * state change.
+ */
+ void performAction(
+ AudioStreamPreference preference,
+ AudioStreamsProgressCategoryController controller,
+ AudioStreamsHelper helper) {}
+
+ /**
+ * The preference summary for the audio stream state (e.g, Scanning...) This method is intended
+ * to be optionally overridden.
+ */
+ @StringRes
+ int getSummary() {
+ return EMPTY_STRING_RES;
+ }
+
+ /**
+ * The preference on click event for the audio stream state (e.g, open up a dialog) This method
+ * is intended to be optionally overridden.
+ */
+ @Nullable
+ Preference.OnPreferenceClickListener getOnClickListener(
+ AudioStreamsProgressCategoryController controller) {
+ return null;
+ }
+
+ /** Subclasses should always override. */
+ AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
+ return AudioStreamsProgressCategoryController.AudioStreamState.UNKNOWN;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceController.java
new file mode 100644
index 0000000..6603a08
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceController.java
@@ -0,0 +1,70 @@
+/*
+ * 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.audiostreams;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.core.BasePreferenceController;
+
+public class AudioStreamsActiveDeviceController extends BasePreferenceController
+ implements AudioStreamsActiveDeviceSummaryUpdater.OnSummaryChangeListener,
+ DefaultLifecycleObserver {
+
+ public static final String KEY = "audio_streams_active_device";
+ private final AudioStreamsActiveDeviceSummaryUpdater mSummaryHelper;
+ @Nullable private Preference mPreference;
+
+ public AudioStreamsActiveDeviceController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ mSummaryHelper = new AudioStreamsActiveDeviceSummaryUpdater(mContext, this);
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mPreference = screen.findPreference(KEY);
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AVAILABLE;
+ }
+
+ @Override
+ public void onSummaryChanged(String summary) {
+ if (mPreference != null) {
+ mPreference.setSummary(summary);
+ }
+ }
+
+ @Override
+ public void onResume(@NonNull LifecycleOwner owner) {
+ mSummaryHelper.register(true);
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ mSummaryHelper.register(false);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdater.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdater.java
new file mode 100644
index 0000000..ab22b07
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsActiveDeviceSummaryUpdater.java
@@ -0,0 +1,106 @@
+/*
+ * 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.audiostreams;
+
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+public class AudioStreamsActiveDeviceSummaryUpdater implements BluetoothCallback {
+ private static final String TAG = "AudioStreamsActiveDeviceSummaryUpdater";
+ private static final boolean DEBUG = BluetoothUtils.D;
+ private final LocalBluetoothManager mBluetoothManager;
+ private Context mContext;
+ @Nullable private String mSummary;
+ private OnSummaryChangeListener mListener;
+
+ public AudioStreamsActiveDeviceSummaryUpdater(
+ Context context, OnSummaryChangeListener listener) {
+ mContext = context;
+ mBluetoothManager = Utils.getLocalBluetoothManager(context);
+ mListener = listener;
+ }
+
+ @Override
+ public void onActiveDeviceChanged(
+ @Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "onActiveDeviceChanged() with activeDevice : "
+ + (activeDevice == null ? "null" : activeDevice.getAddress())
+ + " on profile : "
+ + bluetoothProfile);
+ }
+ if (bluetoothProfile == BluetoothProfile.LE_AUDIO) {
+ notifyChangeIfNeeded();
+ }
+ }
+
+ void register(boolean register) {
+ if (register) {
+ notifyChangeIfNeeded();
+ mBluetoothManager.getEventManager().registerCallback(this);
+ } else {
+ mBluetoothManager.getEventManager().unregisterCallback(this);
+ }
+ }
+
+ private void notifyChangeIfNeeded() {
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ String summary = getSummary();
+ if (!TextUtils.equals(mSummary, summary)) {
+ mSummary = summary;
+ ThreadUtils.postOnMainThread(
+ () -> mListener.onSummaryChanged(summary));
+ }
+ });
+ }
+
+ private String getSummary() {
+ var connectedSink =
+ AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(
+ mBluetoothManager);
+ if (connectedSink.isEmpty()) {
+ return mContext.getString(R.string.audio_streams_dialog_no_le_device_title);
+ }
+ return connectedSink.get().getName();
+ }
+
+ /** Interface definition for a callback to be invoked when the summary has been changed. */
+ interface OnSummaryChangeListener {
+ /**
+ * Called when summary has changed.
+ *
+ * @param summary The new summary.
+ */
+ void onSummaryChanged(String summary);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsBroadcastAssistantCallback.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsBroadcastAssistantCallback.java
new file mode 100644
index 0000000..9fb5b21
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsBroadcastAssistantCallback.java
@@ -0,0 +1,132 @@
+/*
+ * 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.audiostreams;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.util.Log;
+
+import com.android.settingslib.bluetooth.BluetoothUtils;
+
+public class AudioStreamsBroadcastAssistantCallback
+ implements BluetoothLeBroadcastAssistant.Callback {
+
+ private static final String TAG = "AudioStreamsBroadcastAssistantCallback";
+ private static final boolean DEBUG = BluetoothUtils.D;
+
+ @Override
+ public void onReceiveStateChanged(
+ BluetoothDevice sink, int sourceId, BluetoothLeBroadcastReceiveState state) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "onReceiveStateChanged() sink : "
+ + sink.getAddress()
+ + " sourceId: "
+ + sourceId
+ + " state: "
+ + state);
+ }
+ }
+
+ @Override
+ public void onSearchStartFailed(int reason) {
+ Log.w(TAG, "onSearchStartFailed() reason : " + reason);
+ }
+
+ @Override
+ public void onSearchStarted(int reason) {
+ if (DEBUG) {
+ Log.d(TAG, "onSearchStarted() reason : " + reason);
+ }
+ }
+
+ @Override
+ public void onSearchStopFailed(int reason) {
+ Log.w(TAG, "onSearchStopFailed() reason : " + reason);
+ }
+
+ @Override
+ public void onSearchStopped(int reason) {
+ if (DEBUG) {
+ Log.d(TAG, "onSearchStopped() reason : " + reason);
+ }
+ }
+
+ @Override
+ public void onSourceAddFailed(
+ BluetoothDevice sink, BluetoothLeBroadcastMetadata source, int reason) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "onSourceAddFailed() sink : "
+ + sink.getAddress()
+ + " source: "
+ + source
+ + " reason: "
+ + reason);
+ }
+ }
+
+ @Override
+ public void onSourceAdded(BluetoothDevice sink, int sourceId, int reason) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "onSourceAdded() sink : "
+ + sink.getAddress()
+ + " sourceId: "
+ + sourceId
+ + " reason: "
+ + reason);
+ }
+ }
+
+ @Override
+ public void onSourceFound(BluetoothLeBroadcastMetadata source) {
+ if (DEBUG) {
+ Log.d(TAG, "onSourceFound() broadcastId : " + source.getBroadcastId());
+ }
+ }
+
+ @Override
+ public void onSourceLost(int broadcastId) {
+ if (DEBUG) {
+ Log.d(TAG, "onSourceLost() broadcastId : " + broadcastId);
+ }
+ }
+
+ @Override
+ public void onSourceModified(BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceModifyFailed(BluetoothDevice sink, int sourceId, int reason) {}
+
+ @Override
+ public void onSourceRemoveFailed(BluetoothDevice sink, int sourceId, int reason) {
+ Log.w(TAG, "onSourceRemoveFailed() sourceId : " + sourceId + " reason : " + reason);
+ }
+
+ @Override
+ public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
+ if (DEBUG) {
+ Log.d(TAG, "onSourceRemoved() sourceId : " + sourceId + " reason : " + reason);
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryController.java
new file mode 100644
index 0000000..0f164bb
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsCategoryController.java
@@ -0,0 +1,134 @@
+/*
+ * 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.audiostreams;
+
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.LifecycleOwner;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingBasePreferenceController;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.flags.Flags;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+public class AudioStreamsCategoryController extends AudioSharingBasePreferenceController {
+ private static final String TAG = "AudioStreamsCategoryController";
+ private static final boolean DEBUG = BluetoothUtils.D;
+ private final LocalBluetoothManager mLocalBtManager;
+ private final Executor mExecutor;
+ private final BluetoothCallback mBluetoothCallback =
+ new BluetoothCallback() {
+ @Override
+ public void onActiveDeviceChanged(
+ @Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) {
+ if (bluetoothProfile == BluetoothProfile.LE_AUDIO) {
+ updateVisibility();
+ }
+ }
+ };
+
+ public AudioStreamsCategoryController(Context context, String key) {
+ super(context, key);
+ mLocalBtManager = Utils.getLocalBtManager(mContext);
+ mExecutor = Executors.newSingleThreadExecutor();
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ super.onStart(owner);
+ if (mLocalBtManager != null) {
+ mLocalBtManager.getEventManager().registerCallback(mBluetoothCallback);
+ }
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ super.onStop(owner);
+ if (mLocalBtManager != null) {
+ mLocalBtManager.getEventManager().unregisterCallback(mBluetoothCallback);
+ }
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return Flags.enableLeAudioQrCodePrivateBroadcastSharing()
+ ? AVAILABLE
+ : UNSUPPORTED_ON_DEVICE;
+ }
+
+ @Override
+ public void updateVisibility() {
+ if (mPreference == null) return;
+ mExecutor.execute(
+ () -> {
+ if (!isAvailable()) {
+ Log.d(TAG, "skip updateVisibility, unavailable preference");
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> { // Check nullability to pass NullAway check
+ if (mPreference != null) {
+ mPreference.setVisible(false);
+ }
+ });
+ return;
+ }
+ boolean hasConnectedLe =
+ AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(
+ mLocalBtManager)
+ .isPresent();
+ boolean isProfileReady =
+ AudioSharingUtils.isAudioSharingProfileReady(
+ mLocalBtManager.getProfileManager());
+ boolean isBroadcasting = isBroadcasting();
+ boolean isBluetoothOn = isBluetoothStateOn();
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "updateVisibility() isBroadcasting : "
+ + isBroadcasting
+ + " hasConnectedLe : "
+ + hasConnectedLe
+ + " is BT on : "
+ + isBluetoothOn
+ + " is profile ready : "
+ + isProfileReady);
+ }
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> { // Check nullability to pass NullAway check
+ if (mPreference != null) {
+ mPreference.setVisible(
+ isProfileReady
+ && isBluetoothOn
+ && hasConnectedLe
+ && !isBroadcasting);
+ }
+ });
+ });
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDashboardFragment.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDashboardFragment.java
new file mode 100644
index 0000000..330c325
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDashboardFragment.java
@@ -0,0 +1,138 @@
+/*
+ * 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.audiostreams;
+
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsScanQrCodeController.REQUEST_SCAN_BT_BROADCAST_QR_CODE;
+
+import android.app.Activity;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+import com.android.settings.R;
+import com.android.settings.connecteddevice.audiosharing.audiostreams.qrcode.QrCodeScanModeFragment;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+
+import com.google.common.base.Strings;
+
+public class AudioStreamsDashboardFragment extends DashboardFragment {
+ private static final String TAG = "AudioStreamsDashboardFrag";
+ private static final boolean DEBUG = BluetoothUtils.D;
+ private AudioStreamsProgressCategoryController mAudioStreamsProgressCategoryController;
+
+ public AudioStreamsDashboardFragment() {
+ super();
+ }
+
+ @Override
+ public int getMetricsCategory() {
+ // TODO: update category id.
+ return 0;
+ }
+
+ @Override
+ protected String getLogTag() {
+ return TAG;
+ }
+
+ @Override
+ public int getHelpResource() {
+ return R.string.help_url_audio_sharing;
+ }
+
+ @Override
+ protected int getPreferenceScreenResId() {
+ return R.xml.bluetooth_le_audio_streams;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ use(AudioStreamsScanQrCodeController.class).setFragment(this);
+ mAudioStreamsProgressCategoryController = use(AudioStreamsProgressCategoryController.class);
+ mAudioStreamsProgressCategoryController.setFragment(this);
+
+ if (getArguments() != null) {
+ String broadcastMetadataStr =
+ getArguments().getString(AudioStreamConfirmDialog.KEY_BROADCAST_METADATA);
+ if (!Strings.isNullOrEmpty(broadcastMetadataStr)) {
+ BluetoothLeBroadcastMetadata broadcastMetadata =
+ BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(
+ broadcastMetadataStr);
+ if (broadcastMetadata == null) {
+ Log.w(TAG, "onAttach() broadcastMetadata is null!");
+ } else {
+ mAudioStreamsProgressCategoryController.setSourceFromQrCode(broadcastMetadata);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "onActivityResult() requestCode : "
+ + requestCode
+ + " resultCode : "
+ + resultCode);
+ }
+ if (requestCode == REQUEST_SCAN_BT_BROADCAST_QR_CODE) {
+ if (resultCode == Activity.RESULT_OK) {
+ String broadcastMetadata =
+ data != null
+ ? data.getStringExtra(QrCodeScanModeFragment.KEY_BROADCAST_METADATA)
+ : "";
+ BluetoothLeBroadcastMetadata source =
+ BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(
+ broadcastMetadata);
+ if (source == null) {
+ Log.w(TAG, "onActivityResult() source is null!");
+ return;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "onActivityResult() broadcastId : " + source.getBroadcastId());
+ }
+ if (mAudioStreamsProgressCategoryController == null) {
+ Log.w(
+ TAG,
+ "onActivityResult() AudioStreamsProgressCategoryController is null!");
+ return;
+ }
+ mAudioStreamsProgressCategoryController.setSourceFromQrCode(source);
+ }
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDialogFragment.java
new file mode 100644
index 0000000..eb99b96
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDialogFragment.java
@@ -0,0 +1,249 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+
+import com.android.settings.R;
+import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
+
+import com.google.common.base.Strings;
+
+import java.util.function.Consumer;
+
+/** A dialog fragment for constructing and showing audio stream dialogs. */
+public class AudioStreamsDialogFragment extends InstrumentedDialogFragment {
+ private static final String TAG = "AudioStreamsDialogFragment";
+ private final DialogBuilder mDialogBuilder;
+
+ AudioStreamsDialogFragment(DialogBuilder dialogBuilder) {
+ mDialogBuilder = dialogBuilder;
+ }
+
+ @Override
+ public int getMetricsCategory() {
+ // TODO(chelseahao): update metrics id
+ return 0;
+ }
+
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ return mDialogBuilder.build();
+ }
+
+ /**
+ * Displays the audio stream dialog on the specified host fragment.
+ *
+ * @param host The fragment to host the dialog.
+ * @param dialogBuilder The builder for constructing the dialog.
+ */
+ public static void show(Fragment host, DialogBuilder dialogBuilder) {
+ if (!host.isAdded()) {
+ Log.w(TAG, "The host fragment is not added to the activity!");
+ return;
+ }
+ FragmentManager manager = host.getChildFragmentManager();
+ (new AudioStreamsDialogFragment(dialogBuilder)).show(manager, TAG);
+ }
+
+ static void dismissAll(Fragment host) {
+ if (!host.isAdded()) {
+ Log.w(TAG, "The host fragment is not added to the activity!");
+ return;
+ }
+ FragmentManager manager = host.getChildFragmentManager();
+ Fragment dialog = manager.findFragmentByTag(TAG);
+ if (dialog != null
+ && ((DialogFragment) dialog).getDialog() != null
+ && ((DialogFragment) dialog).getDialog().isShowing()) {
+ ((DialogFragment) dialog).dismiss();
+ }
+ }
+
+ @Override
+ public void show(@NonNull FragmentManager manager, @Nullable String tag) {
+ Fragment dialog = manager.findFragmentByTag(TAG);
+ if (dialog != null
+ && ((DialogFragment) dialog).getDialog() != null
+ && ((DialogFragment) dialog).getDialog().isShowing()) {
+ Log.w(TAG, "Dialog already showing, ignore");
+ return;
+ }
+ super.show(manager, tag);
+ }
+
+ /** A builder class for constructing the audio stream dialog. */
+ public static class DialogBuilder {
+ private final Context mContext;
+ private final AlertDialog.Builder mBuilder;
+ @Nullable private String mTitle;
+ @Nullable private String mSubTitle1;
+ @Nullable private String mSubTitle2;
+ @Nullable private String mLeftButtonText;
+ @Nullable private String mRightButtonText;
+ @Nullable private Consumer<AlertDialog> mLeftButtonOnClickListener;
+ @Nullable private Consumer<AlertDialog> mRightButtonOnClickListener;
+
+ /**
+ * Constructs a new instance of DialogBuilder.
+ *
+ * @param context The context used for building the dialog.
+ */
+ public DialogBuilder(Context context) {
+ mContext = context;
+ mBuilder = new AlertDialog.Builder(context);
+ }
+
+ /**
+ * Sets the title of the dialog.
+ *
+ * @param title The title text.
+ * @return This DialogBuilder instance.
+ */
+ public DialogBuilder setTitle(String title) {
+ mTitle = title;
+ return this;
+ }
+
+ /**
+ * Sets the first subtitle of the dialog.
+ *
+ * @param subTitle1 The text of the first subtitle.
+ * @return This DialogBuilder instance.
+ */
+ public DialogBuilder setSubTitle1(String subTitle1) {
+ mSubTitle1 = subTitle1;
+ return this;
+ }
+
+ /**
+ * Sets the second subtitle of the dialog.
+ *
+ * @param subTitle2 The text of the second subtitle.
+ * @return This DialogBuilder instance.
+ */
+ public DialogBuilder setSubTitle2(String subTitle2) {
+ mSubTitle2 = subTitle2;
+ return this;
+ }
+
+ /**
+ * Sets the text of the left button.
+ *
+ * @param text The text of the left button.
+ * @return This DialogBuilder instance.
+ */
+ public DialogBuilder setLeftButtonText(String text) {
+ mLeftButtonText = text;
+ return this;
+ }
+
+ /**
+ * Sets the click listener of the left button.
+ *
+ * @param listener The click listener for the left button.
+ * @return This DialogBuilder instance.
+ */
+ public DialogBuilder setLeftButtonOnClickListener(Consumer<AlertDialog> listener) {
+ mLeftButtonOnClickListener = listener;
+ return this;
+ }
+
+ /**
+ * Sets the text of the right button.
+ *
+ * @param text The text of the right button.
+ * @return This DialogBuilder instance.
+ */
+ public DialogBuilder setRightButtonText(String text) {
+ mRightButtonText = text;
+ return this;
+ }
+
+ /**
+ * Sets the click listener of the right button.
+ *
+ * @param listener The click listener for the right button.
+ * @return This DialogBuilder instance.
+ */
+ public DialogBuilder setRightButtonOnClickListener(Consumer<AlertDialog> listener) {
+ mRightButtonOnClickListener = listener;
+ return this;
+ }
+
+ AlertDialog build() {
+ View rootView =
+ LayoutInflater.from(mContext)
+ .inflate(R.xml.bluetooth_audio_streams_dialog, /* parent= */ null);
+
+ AlertDialog dialog = mBuilder.setView(rootView).setCancelable(false).create();
+ dialog.setCanceledOnTouchOutside(false);
+
+ TextView title = rootView.requireViewById(R.id.dialog_title);
+ title.setText(mTitle);
+
+ if (!Strings.isNullOrEmpty(mSubTitle1)) {
+ TextView subTitle1 = rootView.requireViewById(R.id.dialog_subtitle);
+ subTitle1.setText(mSubTitle1);
+ subTitle1.setVisibility(View.VISIBLE);
+ }
+ if (!Strings.isNullOrEmpty(mSubTitle2)) {
+ TextView subTitle2 = rootView.requireViewById(R.id.dialog_subtitle_2);
+ subTitle2.setText(mSubTitle2);
+ subTitle2.setVisibility(View.VISIBLE);
+ }
+ if (!Strings.isNullOrEmpty(mLeftButtonText)) {
+ Button leftButton = rootView.requireViewById(R.id.left_button);
+ leftButton.setText(mLeftButtonText);
+ leftButton.setVisibility(View.VISIBLE);
+ leftButton.setOnClickListener(
+ unused -> {
+ if (mLeftButtonOnClickListener != null) {
+ mLeftButtonOnClickListener.accept(dialog);
+ }
+ });
+ }
+ if (!Strings.isNullOrEmpty(mRightButtonText)) {
+ Button rightButton = rootView.requireViewById(R.id.right_button);
+ rightButton.setText(mRightButtonText);
+ rightButton.setVisibility(View.VISIBLE);
+ rightButton.setOnClickListener(
+ unused -> {
+ if (mRightButtonOnClickListener != null) {
+ mRightButtonOnClickListener.accept(dialog);
+ }
+ });
+ }
+
+ return dialog;
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java
new file mode 100644
index 0000000..d2b0a8a
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java
@@ -0,0 +1,355 @@
+/*
+ * 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.audiostreams;
+
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.BROADCAST_ID;
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.BROADCAST_TITLE;
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.DEVICES;
+
+import static java.util.Collections.emptyList;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeAudioContentMetadata;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+import com.google.common.base.Strings;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import javax.annotation.Nullable;
+
+/**
+ * A helper class that adds, removes and retrieves LE broadcast sources for all active sink devices.
+ */
+public class AudioStreamsHelper {
+
+ private static final String TAG = "AudioStreamsHelper";
+ private static final boolean DEBUG = BluetoothUtils.D;
+
+ private final @Nullable LocalBluetoothManager mBluetoothManager;
+ private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
+
+ AudioStreamsHelper(@Nullable LocalBluetoothManager bluetoothManager) {
+ mBluetoothManager = bluetoothManager;
+ mLeBroadcastAssistant = getLeBroadcastAssistant(mBluetoothManager);
+ }
+
+ /**
+ * Adds the specified LE broadcast source to all active sinks.
+ *
+ * @param source The LE broadcast metadata representing the audio source.
+ */
+ void addSource(BluetoothLeBroadcastMetadata source) {
+ if (mLeBroadcastAssistant == null) {
+ Log.w(TAG, "addSource(): LeBroadcastAssistant is null!");
+ return;
+ }
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ for (var sink :
+ getConnectedBluetoothDevices(
+ mBluetoothManager, /* inSharingOnly= */ false)) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "addSource(): join broadcast broadcastId"
+ + " : "
+ + source.getBroadcastId()
+ + " sink : "
+ + sink.getAddress());
+ }
+ mLeBroadcastAssistant.addSource(sink, source, false);
+ }
+ });
+ }
+
+ /** Removes sources from LE broadcasts associated for all active sinks based on broadcast Id. */
+ void removeSource(int broadcastId) {
+ if (mLeBroadcastAssistant == null) {
+ Log.w(TAG, "removeSource(): LeBroadcastAssistant is null!");
+ return;
+ }
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ for (var sink :
+ getConnectedBluetoothDevices(
+ mBluetoothManager, /* inSharingOnly= */ true)) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "removeSource(): remove all sources with broadcast id :"
+ + broadcastId
+ + " from sink : "
+ + sink.getAddress());
+ }
+ mLeBroadcastAssistant.getAllSources(sink).stream()
+ .filter(state -> state.getBroadcastId() == broadcastId)
+ .forEach(
+ state ->
+ mLeBroadcastAssistant.removeSource(
+ sink, state.getSourceId()));
+ }
+ });
+ }
+
+ /** Retrieves a list of all LE broadcast receive states from active sinks. */
+ @VisibleForTesting
+ public List<BluetoothLeBroadcastReceiveState> getAllConnectedSources() {
+ if (mLeBroadcastAssistant == null) {
+ Log.w(TAG, "getAllSources(): LeBroadcastAssistant is null!");
+ return emptyList();
+ }
+ return getConnectedBluetoothDevices(mBluetoothManager, /* inSharingOnly= */ true).stream()
+ .flatMap(sink -> mLeBroadcastAssistant.getAllSources(sink).stream())
+ .filter(AudioStreamsHelper::isConnected)
+ .toList();
+ }
+
+ @Nullable
+ LocalBluetoothLeBroadcastAssistant getLeBroadcastAssistant() {
+ return mLeBroadcastAssistant;
+ }
+
+ /** Checks the connectivity status based on the provided broadcast receive state. */
+ public static boolean isConnected(BluetoothLeBroadcastReceiveState state) {
+ return state.getBisSyncState().stream().anyMatch(bitmap -> bitmap != 0);
+ }
+
+ static boolean isBadCode(BluetoothLeBroadcastReceiveState state) {
+ return state.getPaSyncState() == BluetoothLeBroadcastReceiveState.PA_SYNC_STATE_SYNCHRONIZED
+ && state.getBigEncryptionState()
+ == BluetoothLeBroadcastReceiveState.BIG_ENCRYPTION_STATE_BAD_CODE;
+ }
+
+ /**
+ * Returns a {@code CachedBluetoothDevice} that is either connected to a broadcast source or is
+ * a connected LE device.
+ */
+ public static Optional<CachedBluetoothDevice> getCachedBluetoothDeviceInSharingOrLeConnected(
+ @androidx.annotation.Nullable LocalBluetoothManager manager) {
+ if (manager == null) {
+ Log.w(
+ TAG,
+ "getCachedBluetoothDeviceInSharingOrLeConnected(): LocalBluetoothManager is"
+ + " null!");
+ return Optional.empty();
+ }
+ var groupedDevices = AudioSharingUtils.fetchConnectedDevicesByGroupId(manager);
+ var leadDevices =
+ AudioSharingUtils.buildOrderedConnectedLeadDevices(manager, groupedDevices, false);
+ if (leadDevices.isEmpty()) {
+ Log.w(TAG, "getCachedBluetoothDeviceInSharingOrLeConnected(): No lead device!");
+ return Optional.empty();
+ }
+ var deviceHasSource =
+ leadDevices.stream()
+ .filter(device -> hasConnectedBroadcastSource(device, manager))
+ .findFirst();
+ if (deviceHasSource.isPresent()) {
+ Log.d(
+ TAG,
+ "getCachedBluetoothDeviceInSharingOrLeConnected(): Device has connected source"
+ + " found: "
+ + deviceHasSource.get().getAddress());
+ return deviceHasSource;
+ }
+ Log.d(
+ TAG,
+ "getCachedBluetoothDeviceInSharingOrLeConnected(): Device connected found: "
+ + leadDevices.get(0).getAddress());
+ return Optional.of(leadDevices.get(0));
+ }
+
+ /** Returns a {@code CachedBluetoothDevice} that has a connected broadcast source. */
+ static Optional<CachedBluetoothDevice> getCachedBluetoothDeviceInSharing(
+ @androidx.annotation.Nullable LocalBluetoothManager manager) {
+ if (manager == null) {
+ Log.w(TAG, "getCachedBluetoothDeviceInSharing(): LocalBluetoothManager is null!");
+ return Optional.empty();
+ }
+ var groupedDevices = AudioSharingUtils.fetchConnectedDevicesByGroupId(manager);
+ var leadDevices =
+ AudioSharingUtils.buildOrderedConnectedLeadDevices(manager, groupedDevices, false);
+ if (leadDevices.isEmpty()) {
+ Log.w(TAG, "getCachedBluetoothDeviceInSharing(): No lead device!");
+ return Optional.empty();
+ }
+ return leadDevices.stream()
+ .filter(device -> hasConnectedBroadcastSource(device, manager))
+ .findFirst();
+ }
+
+ /**
+ * Check if {@link CachedBluetoothDevice} has connected to a broadcast source.
+ *
+ * @param cachedDevice The cached bluetooth device to check.
+ * @param localBtManager The BT manager to provide BT functions.
+ * @return Whether the device has connected to a broadcast source.
+ */
+ private static boolean hasConnectedBroadcastSource(
+ CachedBluetoothDevice cachedDevice, LocalBluetoothManager localBtManager) {
+ if (localBtManager == null) {
+ Log.d(TAG, "Skip check hasConnectedBroadcastSource due to bt manager is null");
+ return false;
+ }
+ LocalBluetoothLeBroadcastAssistant assistant =
+ localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile();
+ if (assistant == null) {
+ Log.d(TAG, "Skip check hasConnectedBroadcastSource due to assistant profile is null");
+ return false;
+ }
+ List<BluetoothLeBroadcastReceiveState> sourceList =
+ assistant.getAllSources(cachedDevice.getDevice());
+ if (!sourceList.isEmpty()
+ && sourceList.stream().anyMatch(AudioStreamsHelper::isConnected)) {
+ Log.d(
+ TAG,
+ "Lead device has connected broadcast source, device = "
+ + cachedDevice.getDevice().getAnonymizedAddress());
+ return true;
+ }
+ // Return true if member device is in broadcast.
+ for (CachedBluetoothDevice device : cachedDevice.getMemberDevice()) {
+ List<BluetoothLeBroadcastReceiveState> list =
+ assistant.getAllSources(device.getDevice());
+ if (!list.isEmpty() && list.stream().anyMatch(AudioStreamsHelper::isConnected)) {
+ Log.d(
+ TAG,
+ "Member device has connected broadcast source, device = "
+ + device.getDevice().getAnonymizedAddress());
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Retrieves a list of connected Bluetooth devices that belongs to one {@link
+ * CachedBluetoothDevice} that's either connected to a broadcast source or is a connected LE
+ * audio device.
+ */
+ static List<BluetoothDevice> getConnectedBluetoothDevices(
+ @Nullable LocalBluetoothManager manager, boolean inSharingOnly) {
+ if (manager == null) {
+ Log.w(TAG, "getConnectedBluetoothDevices(): LocalBluetoothManager is null!");
+ return emptyList();
+ }
+ var leBroadcastAssistant = getLeBroadcastAssistant(manager);
+ if (leBroadcastAssistant == null) {
+ Log.w(TAG, "getConnectedBluetoothDevices(): LeBroadcastAssistant is null!");
+ return emptyList();
+ }
+ List<BluetoothDevice> connectedDevices =
+ leBroadcastAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED});
+ Optional<CachedBluetoothDevice> cachedBluetoothDevice =
+ inSharingOnly
+ ? getCachedBluetoothDeviceInSharing(manager)
+ : getCachedBluetoothDeviceInSharingOrLeConnected(manager);
+ List<BluetoothDevice> bluetoothDevices =
+ cachedBluetoothDevice
+ .map(
+ c ->
+ Stream.concat(
+ Stream.of(c.getDevice()),
+ c.getMemberDevice().stream()
+ .map(
+ CachedBluetoothDevice
+ ::getDevice))
+ .filter(connectedDevices::contains)
+ .toList())
+ .orElse(emptyList());
+ Log.d(TAG, "getConnectedBluetoothDevices() devices: " + bluetoothDevices);
+ return bluetoothDevices;
+ }
+
+ private static @Nullable LocalBluetoothLeBroadcastAssistant getLeBroadcastAssistant(
+ @Nullable LocalBluetoothManager manager) {
+ if (manager == null) {
+ Log.w(TAG, "getLeBroadcastAssistant(): LocalBluetoothManager is null!");
+ return null;
+ }
+
+ LocalBluetoothProfileManager profileManager = manager.getProfileManager();
+ if (profileManager == null) {
+ Log.w(TAG, "getLeBroadcastAssistant(): LocalBluetoothProfileManager is null!");
+ return null;
+ }
+
+ return profileManager.getLeAudioBroadcastAssistantProfile();
+ }
+
+ static String getBroadcastName(BluetoothLeBroadcastMetadata source) {
+ // TODO(b/331547596): prioritize broadcastName
+ Optional<String> optionalProgramInfo =
+ source.getSubgroups().stream()
+ .map(subgroup -> subgroup.getContentMetadata().getProgramInfo())
+ .filter(programInfo -> !Strings.isNullOrEmpty(programInfo))
+ .findFirst();
+
+ return optionalProgramInfo.orElseGet(
+ () -> {
+ String broadcastName = source.getBroadcastName();
+ if (broadcastName != null && !broadcastName.isEmpty()) {
+ return broadcastName;
+ } else {
+ return "Broadcast Id: " + source.getBroadcastId();
+ }
+ });
+ }
+
+ static String getBroadcastName(BluetoothLeBroadcastReceiveState state) {
+ return state.getSubgroupMetadata().stream()
+ .map(BluetoothLeAudioContentMetadata::getProgramInfo)
+ .filter(i -> !Strings.isNullOrEmpty(i))
+ .findFirst()
+ .orElse("Broadcast Id: " + state.getBroadcastId());
+ }
+
+ void startMediaService(Context context, int audioStreamBroadcastId, String title) {
+ List<BluetoothDevice> devices =
+ getConnectedBluetoothDevices(mBluetoothManager, /* inSharingOnly= */ true);
+ if (devices.isEmpty()) {
+ return;
+ }
+ var intent = new Intent(context, AudioStreamMediaService.class);
+ intent.putExtra(BROADCAST_ID, audioStreamBroadcastId);
+ intent.putExtra(BROADCAST_TITLE, title);
+ intent.putParcelableArrayListExtra(DEVICES, new ArrayList<>(devices));
+ context.startService(intent);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java
new file mode 100644
index 0000000..bc39a42
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryCallback.java
@@ -0,0 +1,122 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.util.Log;
+
+import java.util.Locale;
+
+public class AudioStreamsProgressCategoryCallback extends AudioStreamsBroadcastAssistantCallback {
+ private static final String TAG = "AudioStreamsProgressCategoryCallback";
+
+ private final AudioStreamsProgressCategoryController mCategoryController;
+
+ public AudioStreamsProgressCategoryCallback(
+ AudioStreamsProgressCategoryController audioStreamsProgressCategoryController) {
+ mCategoryController = audioStreamsProgressCategoryController;
+ }
+
+ @Override
+ public void onReceiveStateChanged(
+ BluetoothDevice sink, int sourceId, BluetoothLeBroadcastReceiveState state) {
+ super.onReceiveStateChanged(sink, sourceId, state);
+
+ if (AudioStreamsHelper.isConnected(state)) {
+ mCategoryController.handleSourceConnected(state);
+ } else if (AudioStreamsHelper.isBadCode(state)) {
+ mCategoryController.handleSourceConnectBadCode(state);
+ }
+ }
+
+ @Override
+ public void onSearchStartFailed(int reason) {
+ super.onSearchStartFailed(reason);
+ mCategoryController.showToast(
+ String.format(Locale.US, "Failed to start scanning, reason %d", reason));
+ mCategoryController.setScanning(false);
+ }
+
+ @Override
+ public void onSearchStarted(int reason) {
+ super.onSearchStarted(reason);
+ if (mCategoryController == null) {
+ Log.w(TAG, "onSearchStarted() : mCategoryController is null!");
+ return;
+ }
+ mCategoryController.setScanning(true);
+ }
+
+ @Override
+ public void onSearchStopFailed(int reason) {
+ super.onSearchStopFailed(reason);
+ mCategoryController.showToast(
+ String.format(Locale.US, "Failed to stop scanning, reason %d", reason));
+ }
+
+ @Override
+ public void onSearchStopped(int reason) {
+ super.onSearchStopped(reason);
+ if (mCategoryController == null) {
+ Log.w(TAG, "onSearchStopped() : mCategoryController is null!");
+ return;
+ }
+ mCategoryController.setScanning(false);
+ }
+
+ @Override
+ public void onSourceAddFailed(
+ BluetoothDevice sink, BluetoothLeBroadcastMetadata source, int reason) {
+ super.onSourceAddFailed(sink, source, reason);
+ mCategoryController.handleSourceFailedToConnect(source.getBroadcastId());
+ }
+
+ @Override
+ public void onSourceFound(BluetoothLeBroadcastMetadata source) {
+ super.onSourceFound(source);
+ if (mCategoryController == null) {
+ Log.w(TAG, "onSourceFound() : mCategoryController is null!");
+ return;
+ }
+ mCategoryController.handleSourceFound(source);
+ }
+
+ @Override
+ public void onSourceLost(int broadcastId) {
+ super.onSourceLost(broadcastId);
+ mCategoryController.handleSourceLost(broadcastId);
+ }
+
+ @Override
+ public void onSourceRemoveFailed(BluetoothDevice sink, int sourceId, int reason) {
+ super.onSourceRemoveFailed(sink, sourceId, reason);
+ mCategoryController.showToast(
+ String.format(
+ Locale.US,
+ "Failed to remove source %d for sink %s",
+ sourceId,
+ sink.getAddress()));
+ }
+
+ @Override
+ public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
+ super.onSourceRemoved(sink, sourceId, reason);
+ mCategoryController.handleSourceRemoved();
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java
new file mode 100644
index 0000000..749220f
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java
@@ -0,0 +1,575 @@
+/*
+ * 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.audiostreams;
+
+import static java.util.Collections.emptyList;
+
+import android.app.AlertDialog;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.content.Intent;
+import android.provider.Settings;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+import java.util.Comparator;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+import javax.annotation.Nullable;
+
+public class AudioStreamsProgressCategoryController extends BasePreferenceController
+ implements DefaultLifecycleObserver {
+ private static final String TAG = "AudioStreamsProgressCategoryController";
+ private static final boolean DEBUG = BluetoothUtils.D;
+ private static final int UNSET_BROADCAST_ID = -1;
+ private final BluetoothCallback mBluetoothCallback =
+ new BluetoothCallback() {
+ @Override
+ public void onActiveDeviceChanged(
+ @Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) {
+ if (bluetoothProfile == BluetoothProfile.LE_AUDIO) {
+ mExecutor.execute(() -> init());
+ }
+ }
+ };
+
+ private final Comparator<AudioStreamPreference> mComparator =
+ Comparator.<AudioStreamPreference, Boolean>comparing(
+ p ->
+ p.getAudioStreamState()
+ == AudioStreamsProgressCategoryController
+ .AudioStreamState.SOURCE_ADDED)
+ .thenComparingInt(AudioStreamPreference::getAudioStreamRssi)
+ .reversed();
+
+ public enum AudioStreamState {
+ UNKNOWN,
+ // When mSourceFromQrCode is present and this source has not been synced.
+ WAIT_FOR_SYNC,
+ // When source has been synced but not added to any sink.
+ SYNCED,
+ // When addSource is called for this source and waiting for response.
+ ADD_SOURCE_WAIT_FOR_RESPONSE,
+ // When addSource result in a bad code response.
+ ADD_SOURCE_BAD_CODE,
+ // When addSource result in other bad state.
+ ADD_SOURCE_FAILED,
+ // Source is added to active sink.
+ SOURCE_ADDED,
+ }
+
+ private final Executor mExecutor;
+ private final AudioStreamsProgressCategoryCallback mBroadcastAssistantCallback;
+ private final AudioStreamsHelper mAudioStreamsHelper;
+ private final MediaControlHelper mMediaControlHelper;
+ private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
+ private final @Nullable LocalBluetoothManager mBluetoothManager;
+ private final ConcurrentHashMap<Integer, AudioStreamPreference> mBroadcastIdToPreferenceMap =
+ new ConcurrentHashMap<>();
+ private @Nullable BluetoothLeBroadcastMetadata mSourceFromQrCode;
+ @Nullable private AudioStreamsProgressCategoryPreference mCategoryPreference;
+ @Nullable private AudioStreamsDashboardFragment mFragment;
+
+ public AudioStreamsProgressCategoryController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ mExecutor = Executors.newSingleThreadExecutor();
+ mBluetoothManager = Utils.getLocalBtManager(mContext);
+ mAudioStreamsHelper = new AudioStreamsHelper(mBluetoothManager);
+ mMediaControlHelper = new MediaControlHelper(mContext, mBluetoothManager);
+ mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
+ mBroadcastAssistantCallback = new AudioStreamsProgressCategoryCallback(this);
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AVAILABLE;
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mCategoryPreference = screen.findPreference(getPreferenceKey());
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ if (mBluetoothManager != null) {
+ mBluetoothManager.getEventManager().registerCallback(mBluetoothCallback);
+ }
+ mExecutor.execute(this::init);
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ if (mBluetoothManager != null) {
+ mBluetoothManager.getEventManager().unregisterCallback(mBluetoothCallback);
+ }
+ mExecutor.execute(this::stopScanning);
+ }
+
+ void setFragment(AudioStreamsDashboardFragment fragment) {
+ mFragment = fragment;
+ }
+
+ @Nullable
+ AudioStreamsDashboardFragment getFragment() {
+ return mFragment;
+ }
+
+ void setSourceFromQrCode(BluetoothLeBroadcastMetadata source) {
+ if (DEBUG) {
+ Log.d(TAG, "setSourceFromQrCode(): broadcastId " + source.getBroadcastId());
+ }
+ mSourceFromQrCode = source;
+ }
+
+ void setScanning(boolean isScanning) {
+ ThreadUtils.postOnMainThread(
+ () -> {
+ if (mCategoryPreference != null) mCategoryPreference.setProgress(isScanning);
+ });
+ }
+
+ // Find preference by scanned source and decide next state.
+ // Expect one of the following:
+ // 1) No preference existed, create new preference with state SYNCED
+ // 2) WAIT_FOR_SYNC, move to ADD_SOURCE_WAIT_FOR_RESPONSE
+ // 3) SOURCE_ADDED, leave as-is
+ void handleSourceFound(BluetoothLeBroadcastMetadata source) {
+ if (DEBUG) {
+ Log.d(TAG, "handleSourceFound()");
+ }
+ var broadcastIdFound = source.getBroadcastId();
+
+ if (mSourceFromQrCode != null && mSourceFromQrCode.getBroadcastId() == UNSET_BROADCAST_ID) {
+ // mSourceFromQrCode could have no broadcast Id, we fill in the broadcast Id from the
+ // scanned metadata.
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "handleSourceFound() : processing mSourceFromQrCode with broadcastId"
+ + " unset");
+ }
+ boolean updated =
+ maybeUpdateId(
+ AudioStreamsHelper.getBroadcastName(source), source.getBroadcastId());
+ if (updated && mBroadcastIdToPreferenceMap.containsKey(UNSET_BROADCAST_ID)) {
+ var preference = mBroadcastIdToPreferenceMap.remove(UNSET_BROADCAST_ID);
+ mBroadcastIdToPreferenceMap.put(source.getBroadcastId(), preference);
+ }
+ }
+
+ mBroadcastIdToPreferenceMap.compute(
+ broadcastIdFound,
+ (k, existingPreference) -> {
+ if (existingPreference == null) {
+ return addNewPreference(source, AudioStreamState.SYNCED);
+ }
+ var fromState = existingPreference.getAudioStreamState();
+ if (fromState == AudioStreamState.WAIT_FOR_SYNC && mSourceFromQrCode != null) {
+ // A preference with source founded is existed from a QR code scan. As the
+ // source is now synced, we update the preference with source from scanning
+ // as it includes complete broadcast info.
+ existingPreference.setAudioStreamMetadata(
+ new BluetoothLeBroadcastMetadata.Builder(source)
+ .setBroadcastCode(mSourceFromQrCode.getBroadcastCode())
+ .build());
+ moveToState(
+ existingPreference, AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE);
+ } else {
+ // A preference with source founded existed either because it's already
+ // connected (SOURCE_ADDED). Any other reason is unexpected. We update the
+ // preference with this source and won't change it's state.
+ existingPreference.setAudioStreamMetadata(source);
+ if (fromState != AudioStreamState.SOURCE_ADDED) {
+ Log.w(
+ TAG,
+ "handleSourceFound(): unexpected state : "
+ + fromState
+ + " for broadcastId : "
+ + broadcastIdFound);
+ }
+ }
+ return existingPreference;
+ });
+ }
+
+ private boolean maybeUpdateId(String targetBroadcastName, int broadcastIdToSet) {
+ if (mSourceFromQrCode == null) {
+ return false;
+ }
+ if (targetBroadcastName.equals(AudioStreamsHelper.getBroadcastName(mSourceFromQrCode))) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "maybeUpdateId() : updating unset broadcastId for metadataFromQrCode with"
+ + " broadcastName: "
+ + AudioStreamsHelper.getBroadcastName(mSourceFromQrCode)
+ + " to broadcast Id: "
+ + broadcastIdToSet);
+ }
+ mSourceFromQrCode =
+ new BluetoothLeBroadcastMetadata.Builder(mSourceFromQrCode)
+ .setBroadcastId(broadcastIdToSet)
+ .build();
+ return true;
+ }
+ return false;
+ }
+
+ // Find preference by mSourceFromQrCode and decide next state.
+ // Expect no preference existed, create new preference with state WAIT_FOR_SYNC
+ private void handleSourceFromQrCodeIfExists() {
+ if (DEBUG) {
+ Log.d(TAG, "handleSourceFromQrCodeIfExists()");
+ }
+ if (mSourceFromQrCode == null) {
+ return;
+ }
+ mBroadcastIdToPreferenceMap.compute(
+ mSourceFromQrCode.getBroadcastId(),
+ (k, existingPreference) -> {
+ if (existingPreference == null) {
+ // No existing preference for this source from the QR code scan, add one and
+ // set initial state to WAIT_FOR_SYNC.
+ // Check nullability to bypass NullAway check.
+ if (mSourceFromQrCode != null) {
+ return addNewPreference(
+ mSourceFromQrCode, AudioStreamState.WAIT_FOR_SYNC);
+ }
+ }
+ Log.w(
+ TAG,
+ "handleSourceFromQrCodeIfExists(): unexpected state : "
+ + existingPreference.getAudioStreamState()
+ + " for broadcastId : "
+ + (mSourceFromQrCode == null
+ ? "null"
+ : mSourceFromQrCode.getBroadcastId()));
+ return existingPreference;
+ });
+ }
+
+ void handleSourceLost(int broadcastId) {
+ if (DEBUG) {
+ Log.d(TAG, "handleSourceLost()");
+ }
+ if (mAudioStreamsHelper.getAllConnectedSources().stream()
+ .anyMatch(connected -> connected.getBroadcastId() == broadcastId)) {
+ Log.d(
+ TAG,
+ "handleSourceLost() : keep this preference as the source is still connected.");
+ return;
+ }
+ var toRemove = mBroadcastIdToPreferenceMap.remove(broadcastId);
+ if (toRemove != null) {
+ ThreadUtils.postOnMainThread(
+ () -> {
+ if (mCategoryPreference != null) {
+ mCategoryPreference.removePreference(toRemove);
+ }
+ });
+ }
+ }
+
+ void handleSourceRemoved() {
+ if (DEBUG) {
+ Log.d(TAG, "handleSourceRemoved()");
+ }
+ for (var entry : mBroadcastIdToPreferenceMap.entrySet()) {
+ var preference = entry.getValue();
+
+ // Look for preference has SOURCE_ADDED state, re-check if they are still connected. If
+ // not, means the source is removed from the sink, we move back the preference to SYNCED
+ // state.
+ if (preference.getAudioStreamState() == AudioStreamState.SOURCE_ADDED
+ && mAudioStreamsHelper.getAllConnectedSources().stream()
+ .noneMatch(
+ connected ->
+ connected.getBroadcastId()
+ == preference.getAudioStreamBroadcastId())) {
+
+ ThreadUtils.postOnMainThread(
+ () -> {
+ var metadata = preference.getAudioStreamMetadata();
+
+ if (metadata != null) {
+ moveToState(preference, AudioStreamState.SYNCED);
+ } else {
+ handleSourceLost(preference.getAudioStreamBroadcastId());
+ }
+ });
+
+ return;
+ }
+ }
+ }
+
+ // Find preference by receiveState and decide next state.
+ // Expect one of the following:
+ // 1) No preference existed, create new preference with state SOURCE_ADDED
+ // 2) Any other state, move to SOURCE_ADDED
+ void handleSourceConnected(BluetoothLeBroadcastReceiveState receiveState) {
+ if (DEBUG) {
+ Log.d(TAG, "handleSourceConnected()");
+ }
+ if (!AudioStreamsHelper.isConnected(receiveState)) {
+ return;
+ }
+ var broadcastIdConnected = receiveState.getBroadcastId();
+ if (mSourceFromQrCode != null && mSourceFromQrCode.getBroadcastId() == UNSET_BROADCAST_ID) {
+ // mSourceFromQrCode could have no broadcast Id, we fill in the broadcast Id from the
+ // connected source receiveState.
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "handleSourceConnected() : processing mSourceFromQrCode with broadcastId"
+ + " unset");
+ }
+ boolean updated =
+ maybeUpdateId(
+ AudioStreamsHelper.getBroadcastName(receiveState),
+ receiveState.getBroadcastId());
+ if (updated && mBroadcastIdToPreferenceMap.containsKey(UNSET_BROADCAST_ID)) {
+ var preference = mBroadcastIdToPreferenceMap.remove(UNSET_BROADCAST_ID);
+ mBroadcastIdToPreferenceMap.put(receiveState.getBroadcastId(), preference);
+ }
+ }
+
+ mBroadcastIdToPreferenceMap.compute(
+ broadcastIdConnected,
+ (k, existingPreference) -> {
+ if (existingPreference == null) {
+ // No existing preference for this source even if it's already connected,
+ // add one and set initial state to SOURCE_ADDED. This could happen because
+ // we retrieves the connected source during onStart() from
+ // AudioStreamsHelper#getAllConnectedSources() even before the source is
+ // founded by scanning.
+ return addNewPreference(receiveState, AudioStreamState.SOURCE_ADDED);
+ }
+ if (existingPreference.getAudioStreamState() == AudioStreamState.WAIT_FOR_SYNC
+ && existingPreference.getAudioStreamBroadcastId() == UNSET_BROADCAST_ID
+ && mSourceFromQrCode != null) {
+ existingPreference.setAudioStreamMetadata(mSourceFromQrCode);
+ }
+ moveToState(existingPreference, AudioStreamState.SOURCE_ADDED);
+ return existingPreference;
+ });
+ }
+
+ // Find preference by receiveState and decide next state.
+ // Expect one preference existed, move to ADD_SOURCE_BAD_CODE
+ void handleSourceConnectBadCode(BluetoothLeBroadcastReceiveState receiveState) {
+ if (DEBUG) {
+ Log.d(TAG, "handleSourceConnectBadCode()");
+ }
+ if (!AudioStreamsHelper.isBadCode(receiveState)) {
+ return;
+ }
+ mBroadcastIdToPreferenceMap.computeIfPresent(
+ receiveState.getBroadcastId(),
+ (k, existingPreference) -> {
+ moveToState(existingPreference, AudioStreamState.ADD_SOURCE_BAD_CODE);
+ return existingPreference;
+ });
+ }
+
+ // Find preference by broadcastId and decide next state.
+ // Expect one preference existed, move to ADD_SOURCE_FAILED
+ void handleSourceFailedToConnect(int broadcastId) {
+ if (DEBUG) {
+ Log.d(TAG, "handleSourceFailedToConnect()");
+ }
+ mBroadcastIdToPreferenceMap.computeIfPresent(
+ broadcastId,
+ (k, existingPreference) -> {
+ moveToState(existingPreference, AudioStreamState.ADD_SOURCE_FAILED);
+ return existingPreference;
+ });
+ }
+
+ // Find preference by metadata and decide next state.
+ // Expect one preference existed, move to ADD_SOURCE_WAIT_FOR_RESPONSE
+ void handleSourceAddRequest(
+ AudioStreamPreference preference, BluetoothLeBroadcastMetadata metadata) {
+ if (DEBUG) {
+ Log.d(TAG, "handleSourceAddRequest()");
+ }
+ mBroadcastIdToPreferenceMap.computeIfPresent(
+ metadata.getBroadcastId(),
+ (k, existingPreference) -> {
+ if (!existingPreference.equals(preference)) {
+ Log.w(TAG, "handleSourceAddedRequest(): existing preference not match");
+ }
+ existingPreference.setAudioStreamMetadata(metadata);
+ moveToState(existingPreference, AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE);
+ return existingPreference;
+ });
+ }
+
+ void showToast(String msg) {
+ AudioSharingUtils.toastMessage(mContext, msg);
+ }
+
+ private void init() {
+ mBroadcastIdToPreferenceMap.clear();
+ boolean hasConnected =
+ AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(mBluetoothManager)
+ .isPresent();
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mCategoryPreference != null) {
+ mCategoryPreference.removeAudioStreamPreferences();
+ mCategoryPreference.setVisible(hasConnected);
+ }
+ });
+ if (hasConnected) {
+ startScanning();
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mFragment != null) {
+ AudioStreamsDialogFragment.dismissAll(mFragment);
+ }
+ });
+ } else {
+ stopScanning();
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mFragment != null) {
+ AudioStreamsDialogFragment.show(mFragment, getNoLeDeviceDialog());
+ }
+ });
+ }
+ }
+
+ private void startScanning() {
+ if (mLeBroadcastAssistant == null) {
+ Log.w(TAG, "startScanning(): LeBroadcastAssistant is null!");
+ return;
+ }
+ if (mLeBroadcastAssistant.isSearchInProgress()) {
+ Log.w(TAG, "startScanning(): scanning still in progress, stop scanning first.");
+ stopScanning();
+ }
+ mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
+ mExecutor.execute(
+ () -> {
+ // Handle QR code scan, display currently connected streams then start scanning
+ // sequentially
+ handleSourceFromQrCodeIfExists();
+ mAudioStreamsHelper
+ .getAllConnectedSources()
+ .forEach(this::handleSourceConnected);
+ mLeBroadcastAssistant.startSearchingForSources(emptyList());
+ mMediaControlHelper.start();
+ });
+ }
+
+ private void stopScanning() {
+ if (mLeBroadcastAssistant == null) {
+ Log.w(TAG, "stopScanning(): LeBroadcastAssistant is null!");
+ return;
+ }
+ if (mLeBroadcastAssistant.isSearchInProgress()) {
+ if (DEBUG) {
+ Log.d(TAG, "stopScanning()");
+ }
+ mLeBroadcastAssistant.stopSearchingForSources();
+ mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
+ }
+ mMediaControlHelper.stop();
+ mSourceFromQrCode = null;
+ }
+
+ private AudioStreamPreference addNewPreference(
+ BluetoothLeBroadcastReceiveState receiveState, AudioStreamState state) {
+ var preference = AudioStreamPreference.fromReceiveState(mContext, receiveState);
+ moveToState(preference, state);
+ return preference;
+ }
+
+ private AudioStreamPreference addNewPreference(
+ BluetoothLeBroadcastMetadata metadata, AudioStreamState state) {
+ var preference = AudioStreamPreference.fromMetadata(mContext, metadata);
+ moveToState(preference, state);
+ return preference;
+ }
+
+ private void moveToState(AudioStreamPreference preference, AudioStreamState state) {
+ AudioStreamStateHandler stateHandler = switch (state) {
+ case SYNCED -> SyncedState.getInstance();
+ case WAIT_FOR_SYNC -> WaitForSyncState.getInstance();
+ case ADD_SOURCE_WAIT_FOR_RESPONSE ->
+ AddSourceWaitForResponseState.getInstance();
+ case ADD_SOURCE_BAD_CODE -> AddSourceBadCodeState.getInstance();
+ case ADD_SOURCE_FAILED -> AddSourceFailedState.getInstance();
+ case SOURCE_ADDED -> SourceAddedState.getInstance();
+ default -> throw new IllegalArgumentException("Unsupported state: " + state);
+ };
+
+ stateHandler.handleStateChange(preference, this, mAudioStreamsHelper);
+
+ // Update UI with the updated preference
+ AudioSharingUtils.postOnMainThread(
+ mContext,
+ () -> {
+ if (mCategoryPreference != null) {
+ mCategoryPreference.addAudioStreamPreference(preference, mComparator);
+ }
+ });
+ }
+
+ private AudioStreamsDialogFragment.DialogBuilder getNoLeDeviceDialog() {
+ return new AudioStreamsDialogFragment.DialogBuilder(mContext)
+ .setTitle(mContext.getString(R.string.audio_streams_dialog_no_le_device_title))
+ .setSubTitle2(
+ mContext.getString(R.string.audio_streams_dialog_no_le_device_subtitle))
+ .setLeftButtonText(mContext.getString(R.string.audio_streams_dialog_close))
+ .setLeftButtonOnClickListener(AlertDialog::dismiss)
+ .setRightButtonText(
+ mContext.getString(R.string.audio_streams_dialog_no_le_device_button))
+ .setRightButtonOnClickListener(
+ dialog -> {
+ mContext.startActivity(
+ new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
+ .setPackage(mContext.getPackageName()));
+ dialog.dismiss();
+ });
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryPreference.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryPreference.java
new file mode 100644
index 0000000..33adc31
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryPreference.java
@@ -0,0 +1,89 @@
+/*
+ * 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.audiostreams;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+import androidx.annotation.NonNull;
+
+import com.android.settings.ProgressCategory;
+import com.android.settings.R;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+public class AudioStreamsProgressCategoryPreference extends ProgressCategory {
+
+ public AudioStreamsProgressCategoryPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ public AudioStreamsProgressCategoryPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public AudioStreamsProgressCategoryPreference(
+ Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public AudioStreamsProgressCategoryPreference(
+ Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ void addAudioStreamPreference(
+ @NonNull AudioStreamPreference preference,
+ Comparator<AudioStreamPreference> comparator) {
+ super.addPreference(preference);
+
+ List<AudioStreamPreference> preferences = getAllAudioStreamPreferences();
+ preferences.sort(comparator);
+ for (int i = 0; i < preferences.size(); i++) {
+ // setOrder to i + 1, since the order 0 preference should always be the
+ // "audio_streams_scan_qr_code"
+ preferences.get(i).setOrder(i + 1);
+ }
+ }
+
+ void removeAudioStreamPreferences() {
+ List<AudioStreamPreference> streams = getAllAudioStreamPreferences();
+ for (var toRemove : streams) {
+ removePreference(toRemove);
+ }
+ }
+
+ private List<AudioStreamPreference> getAllAudioStreamPreferences() {
+ List<AudioStreamPreference> streams = new ArrayList<>();
+ for (int i = 0; i < getPreferenceCount(); i++) {
+ if (getPreference(i) instanceof AudioStreamPreference) {
+ streams.add((AudioStreamPreference) getPreference(i));
+ }
+ }
+ return streams;
+ }
+
+ private void init() {
+ setEmptyTextRes(R.string.audio_streams_empty);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsQrCodeFragment.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsQrCodeFragment.java
new file mode 100644
index 0000000..4b6dfa5
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsQrCodeFragment.java
@@ -0,0 +1,134 @@
+/*
+ * 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.audiostreams;
+
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.core.InstrumentedFragment;
+import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.qrcode.QrCodeGenerator;
+
+import com.google.zxing.WriterException;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+public class AudioStreamsQrCodeFragment extends InstrumentedFragment {
+ private static final String TAG = "AudioStreamsQrCodeFragment";
+
+ @Override
+ public int getMetricsCategory() {
+ // TODO(chelseahao): update metrics id
+ return 0;
+ }
+
+ @Override
+ public final View onCreateView(
+ LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View view = inflater.inflate(R.xml.bluetooth_audio_streams_qr_code, container, false);
+
+ BluetoothLeBroadcastMetadata broadcastMetadata = getBroadcastMetadata();
+
+ if (broadcastMetadata != null) {
+ Optional<Bitmap> bm = getQrCodeBitmap(broadcastMetadata);
+ if (bm.isEmpty()) {
+ return view;
+ }
+ ((ImageView) view.requireViewById(R.id.qrcode_view)).setImageBitmap(bm.get());
+ if (broadcastMetadata.getBroadcastCode() != null) {
+ String password =
+ new String(broadcastMetadata.getBroadcastCode(), StandardCharsets.UTF_8);
+ String passwordText =
+ getContext()
+ .getString(R.string.audio_streams_qr_code_page_password, password);
+ ((TextView) view.requireViewById(R.id.password)).setText(passwordText);
+ }
+ TextView summaryView = view.requireViewById(android.R.id.summary);
+ String summary =
+ view.getContext()
+ .getString(
+ R.string.audio_streams_qr_code_page_description,
+ broadcastMetadata.getBroadcastName());
+ summaryView.setText(summary);
+ }
+ return view;
+ }
+
+ private Optional<Bitmap> getQrCodeBitmap(@Nullable BluetoothLeBroadcastMetadata metadata) {
+ if (metadata == null) {
+ Log.d(TAG, "onCreateView: broadcastMetadata is empty!");
+ return Optional.empty();
+ }
+ String metadataStr = BluetoothLeBroadcastMetadataExt.INSTANCE.toQrCodeString(metadata);
+ if (metadataStr.isEmpty()) {
+ Log.d(TAG, "onCreateView: metadataStr is empty!");
+ return Optional.empty();
+ }
+ Log.i(TAG, "onCreateView: metadataStr : " + metadataStr);
+ try {
+ int qrcodeSize =
+ getContext()
+ .getResources()
+ .getDimensionPixelSize(R.dimen.audio_streams_qrcode_size);
+ Bitmap bitmap = QrCodeGenerator.encodeQrCode(metadataStr, qrcodeSize);
+ return Optional.of(bitmap);
+ } catch (WriterException e) {
+ Log.d(
+ TAG,
+ "onCreateView: broadcastMetadata "
+ + metadata
+ + " qrCode generation exception "
+ + e);
+ }
+
+ return Optional.empty();
+ }
+
+ @Nullable
+ private BluetoothLeBroadcastMetadata getBroadcastMetadata() {
+ LocalBluetoothLeBroadcast localBluetoothLeBroadcast =
+ Utils.getLocalBtManager(getActivity())
+ .getProfileManager()
+ .getLeAudioBroadcastProfile();
+ if (localBluetoothLeBroadcast == null) {
+ Log.d(TAG, "getBroadcastMetadataQrCode: localBluetoothLeBroadcast is null!");
+ return null;
+ }
+
+ BluetoothLeBroadcastMetadata metadata =
+ localBluetoothLeBroadcast.getLatestBluetoothLeBroadcastMetadata();
+ if (metadata == null) {
+ Log.d(TAG, "getBroadcastMetadataQrCode: metadata is null!");
+ return null;
+ }
+
+ return metadata;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsRepository.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsRepository.java
new file mode 100644
index 0000000..2c60e85
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsRepository.java
@@ -0,0 +1,159 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+
+import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.utils.ThreadUtils;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.annotation.Nullable;
+
+/** Manages the caching and storage of Bluetooth audio stream metadata. */
+public class AudioStreamsRepository {
+
+ private static final String TAG = "AudioStreamsRepository";
+ private static final boolean DEBUG = BluetoothUtils.D;
+
+ private static final String PREF_KEY = "bluetooth_audio_stream_pref";
+ private static final String METADATA_KEY = "bluetooth_audio_stream_metadata";
+
+ @Nullable private static AudioStreamsRepository sInstance = null;
+
+ private AudioStreamsRepository() {}
+
+ /**
+ * Gets the single instance of AudioStreamsRepository.
+ *
+ * @return The AudioStreamsRepository instance.
+ */
+ public static synchronized AudioStreamsRepository getInstance() {
+ if (sInstance == null) {
+ sInstance = new AudioStreamsRepository();
+ }
+ return sInstance;
+ }
+
+ private final ConcurrentHashMap<Integer, BluetoothLeBroadcastMetadata>
+ mBroadcastIdToMetadataCacheMap = new ConcurrentHashMap<>();
+
+ /**
+ * Caches BluetoothLeBroadcastMetadata in a local cache.
+ *
+ * @param metadata The BluetoothLeBroadcastMetadata to be cached.
+ */
+ void cacheMetadata(BluetoothLeBroadcastMetadata metadata) {
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "cacheMetadata(): broadcastId "
+ + metadata.getBroadcastId()
+ + " saved in local cache.");
+ }
+ mBroadcastIdToMetadataCacheMap.put(metadata.getBroadcastId(), metadata);
+ }
+
+ /**
+ * Gets cached BluetoothLeBroadcastMetadata by broadcastId.
+ *
+ * @param broadcastId The broadcastId to look up in the cache.
+ * @return The cached BluetoothLeBroadcastMetadata or null if not found.
+ */
+ @Nullable
+ BluetoothLeBroadcastMetadata getCachedMetadata(int broadcastId) {
+ var metadata = mBroadcastIdToMetadataCacheMap.get(broadcastId);
+ if (metadata == null) {
+ Log.w(
+ TAG,
+ "getCachedMetadata(): broadcastId not found in"
+ + " mBroadcastIdToMetadataCacheMap.");
+ return null;
+ }
+ return metadata;
+ }
+
+ /**
+ * Saves metadata to SharedPreferences asynchronously.
+ *
+ * @param context The context.
+ * @param metadata The BluetoothLeBroadcastMetadata to be saved.
+ */
+ void saveMetadata(Context context, BluetoothLeBroadcastMetadata metadata) {
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ SharedPreferences sharedPref =
+ context.getSharedPreferences(PREF_KEY, Context.MODE_PRIVATE);
+ if (sharedPref != null) {
+ SharedPreferences.Editor editor = sharedPref.edit();
+ editor.putString(
+ METADATA_KEY,
+ BluetoothLeBroadcastMetadataExt.INSTANCE.toQrCodeString(
+ metadata));
+ editor.apply();
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "saveMetadata(): broadcastId "
+ + metadata.getBroadcastId()
+ + " metadata saved in storage.");
+ }
+ }
+ });
+ }
+
+ /**
+ * Gets saved metadata from SharedPreferences.
+ *
+ * @param context The context.
+ * @param broadcastId The broadcastId to retrieve metadata for.
+ * @return The saved BluetoothLeBroadcastMetadata or null if not found.
+ */
+ @Nullable
+ BluetoothLeBroadcastMetadata getSavedMetadata(Context context, int broadcastId) {
+ SharedPreferences sharedPref = context.getSharedPreferences(PREF_KEY, Context.MODE_PRIVATE);
+ if (sharedPref != null) {
+ String savedMetadataStr = sharedPref.getString(METADATA_KEY, null);
+ if (savedMetadataStr == null) {
+ Log.w(TAG, "getSavedMetadata(): savedMetadataStr is null");
+ return null;
+ }
+ var savedMetadata =
+ BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(
+ savedMetadataStr);
+ if (savedMetadata == null || savedMetadata.getBroadcastId() != broadcastId) {
+ Log.w(TAG, "getSavedMetadata(): savedMetadata doesn't match broadcast Id.");
+ return null;
+ }
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "getSavedMetadata(): broadcastId "
+ + savedMetadata.getBroadcastId()
+ + " metadata found in storage.");
+ }
+ return savedMetadata;
+ }
+ return null;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeController.java
new file mode 100644
index 0000000..b63ec7e
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsScanQrCodeController.java
@@ -0,0 +1,140 @@
+/*
+ * 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.audiostreams;
+
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.audiosharing.audiostreams.qrcode.QrCodeScanModeActivity;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.bluetooth.BluetoothBroadcastUtils;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.utils.ThreadUtils;
+
+public class AudioStreamsScanQrCodeController extends BasePreferenceController
+ implements DefaultLifecycleObserver {
+ static final int REQUEST_SCAN_BT_BROADCAST_QR_CODE = 0;
+ private static final String TAG = "AudioStreamsProgressCategoryController";
+ private static final boolean DEBUG = BluetoothUtils.D;
+ private static final String KEY = "audio_streams_scan_qr_code";
+ private final BluetoothCallback mBluetoothCallback =
+ new BluetoothCallback() {
+ @Override
+ public void onActiveDeviceChanged(
+ @Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) {
+ if (bluetoothProfile == BluetoothProfile.LE_AUDIO) {
+ updateVisibility();
+ }
+ }
+ };
+
+ @Nullable private final LocalBluetoothManager mLocalBtManager;
+ @Nullable private AudioStreamsDashboardFragment mFragment;
+ @Nullable private Preference mPreference;
+
+ public AudioStreamsScanQrCodeController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ mLocalBtManager = Utils.getLocalBtManager(mContext);
+ }
+
+ public void setFragment(AudioStreamsDashboardFragment fragment) {
+ mFragment = fragment;
+ }
+
+ @Override
+ public void onStart(@NonNull LifecycleOwner owner) {
+ if (mLocalBtManager != null) {
+ mLocalBtManager.getEventManager().registerCallback(mBluetoothCallback);
+ }
+ }
+
+ @Override
+ public void onStop(@NonNull LifecycleOwner owner) {
+ if (mLocalBtManager != null) {
+ mLocalBtManager.getEventManager().unregisterCallback(mBluetoothCallback);
+ }
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return AVAILABLE;
+ }
+
+ @Override
+ public String getPreferenceKey() {
+ return KEY;
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mPreference = screen.findPreference(getPreferenceKey());
+ if (mPreference == null) {
+ Log.w(TAG, "displayPreference() mPreference is null!");
+ return;
+ }
+ mPreference.setOnPreferenceClickListener(
+ preference -> {
+ if (mFragment == null) {
+ Log.w(TAG, "displayPreference() mFragment is null!");
+ return false;
+ }
+ if (preference.getKey().equals(KEY)) {
+ Intent intent = new Intent(mContext, QrCodeScanModeActivity.class);
+ intent.setAction(
+ BluetoothBroadcastUtils.ACTION_BLUETOOTH_LE_AUDIO_QR_CODE_SCANNER);
+ mFragment.startActivityForResult(intent, REQUEST_SCAN_BT_BROADCAST_QR_CODE);
+ if (DEBUG) {
+ Log.w(TAG, "displayPreference() sent intent : " + intent);
+ }
+ return true;
+ }
+ return false;
+ });
+ }
+
+ private void updateVisibility() {
+ var unused =
+ ThreadUtils.postOnBackgroundThread(
+ () -> {
+ boolean hasConnectedLe =
+ AudioStreamsHelper
+ .getCachedBluetoothDeviceInSharingOrLeConnected(
+ mLocalBtManager)
+ .isPresent();
+ ThreadUtils.postOnMainThread(
+ () -> {
+ if (mPreference != null) {
+ mPreference.setVisible(hasConnectedLe);
+ }
+ });
+ });
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/MediaControlHelper.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/MediaControlHelper.java
new file mode 100644
index 0000000..0f6b786
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/MediaControlHelper.java
@@ -0,0 +1,133 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.content.Context;
+import android.media.session.MediaController;
+import android.media.session.MediaSessionManager;
+import android.media.session.PlaybackState;
+import android.util.Log;
+import android.util.Pair;
+
+import androidx.annotation.Nullable;
+
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.media.BluetoothMediaDevice;
+import com.android.settingslib.media.LocalMediaManager;
+import com.android.settingslib.media.MediaDevice;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+class MediaControlHelper {
+ private static final String TAG = "MediaControlHelper";
+ private final Context mContext;
+ private final MediaSessionManager mMediaSessionManager;
+ @Nullable private final LocalBluetoothManager mLocalBluetoothManager;
+ private final List<Pair<LocalMediaManager, LocalMediaManager.DeviceCallback>>
+ mLocalMediaManagers = new ArrayList<>();
+
+ MediaControlHelper(Context context, @Nullable LocalBluetoothManager localBluetoothManager) {
+ mContext = context;
+ mMediaSessionManager = context.getSystemService(MediaSessionManager.class);
+ mLocalBluetoothManager = localBluetoothManager;
+ }
+
+ void start() {
+ if (mLocalBluetoothManager == null) {
+ return;
+ }
+ var currentLeDevice =
+ AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(
+ mLocalBluetoothManager);
+ if (currentLeDevice.isEmpty()) {
+ Log.d(TAG, "start() : current LE device is empty!");
+ return;
+ }
+
+ for (MediaController controller : mMediaSessionManager.getActiveSessions(null)) {
+ String packageName = controller.getPackageName();
+
+ // We won't stop media created from settings.
+ if (Objects.equals(packageName, mContext.getPackageName())) {
+ Log.d(TAG, "start() : skip package: " + packageName);
+ continue;
+ }
+
+ // Start scanning and listen to device list update, stop this media if device matched.
+ var localMediaManager = new LocalMediaManager(mContext, packageName);
+ var deviceCallback =
+ new LocalMediaManager.DeviceCallback() {
+ public void onDeviceListUpdate(List<MediaDevice> devices) {
+ if (shouldStopMedia(
+ controller,
+ currentLeDevice.get(),
+ localMediaManager.getCurrentConnectedDevice())) {
+ Log.d(
+ TAG,
+ "start() : Stopping media player for package: "
+ + controller.getPackageName());
+ var controls = controller.getTransportControls();
+ if (controls != null) {
+ controls.stop();
+ }
+ }
+ }
+ };
+ localMediaManager.registerCallback(deviceCallback);
+ localMediaManager.startScan();
+ mLocalMediaManagers.add(new Pair<>(localMediaManager, deviceCallback));
+ }
+ }
+
+ void stop() {
+ mLocalMediaManagers.forEach(
+ m -> {
+ m.first.stopScan();
+ m.first.unregisterCallback(m.second);
+ });
+ mLocalMediaManagers.clear();
+ }
+
+ private static boolean shouldStopMedia(
+ MediaController controller,
+ CachedBluetoothDevice currentLeDevice,
+ MediaDevice currentMediaDevice) {
+ // We won't stop media if it's already stopped.
+ if (controller.getPlaybackState() != null
+ && controller.getPlaybackState().getState() == PlaybackState.STATE_STOPPED) {
+ Log.d(TAG, "shouldStopMedia() : skip already stopped: " + controller.getPackageName());
+ return false;
+ }
+
+ var deviceForMedia =
+ currentMediaDevice instanceof BluetoothMediaDevice
+ ? (BluetoothMediaDevice) currentMediaDevice
+ : null;
+ return deviceForMedia != null
+ && hasOverlap(deviceForMedia.getCachedDevice(), currentLeDevice);
+ }
+
+ private static boolean hasOverlap(
+ CachedBluetoothDevice device1, CachedBluetoothDevice device2) {
+ return device1.equals(device2)
+ || device1.getMemberDevice().contains(device2)
+ || device2.getMemberDevice().contains(device1);
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java
new file mode 100644
index 0000000..4fdaf15
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedState.java
@@ -0,0 +1,94 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.app.settings.SettingsEnums;
+import android.os.Bundle;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.preference.Preference;
+
+import com.android.settings.R;
+import com.android.settings.core.SubSettingLauncher;
+
+class SourceAddedState extends AudioStreamStateHandler {
+ @VisibleForTesting
+ static final int AUDIO_STREAM_SOURCE_ADDED_STATE_SUMMARY = R.string.audio_streams_listening_now;
+
+ @Nullable private static SourceAddedState sInstance = null;
+
+ private SourceAddedState() {}
+
+ static SourceAddedState getInstance() {
+ if (sInstance == null) {
+ sInstance = new SourceAddedState();
+ }
+ return sInstance;
+ }
+
+ @Override
+ void performAction(
+ AudioStreamPreference preference,
+ AudioStreamsProgressCategoryController controller,
+ AudioStreamsHelper helper) {
+ var context = preference.getContext();
+ // Saved connected metadata for user to re-join this broadcast later.
+ var cached =
+ mAudioStreamsRepository.getCachedMetadata(preference.getAudioStreamBroadcastId());
+ if (cached != null) {
+ mAudioStreamsRepository.saveMetadata(context, cached);
+ }
+ helper.startMediaService(
+ context,
+ preference.getAudioStreamBroadcastId(),
+ String.valueOf(preference.getTitle()));
+ }
+
+ @Override
+ int getSummary() {
+ return AUDIO_STREAM_SOURCE_ADDED_STATE_SUMMARY;
+ }
+
+ @Override
+ Preference.OnPreferenceClickListener getOnClickListener(
+ AudioStreamsProgressCategoryController controller) {
+ return preference -> {
+ var p = (AudioStreamPreference) preference;
+ Bundle broadcast = new Bundle();
+ broadcast.putString(
+ AudioStreamDetailsFragment.BROADCAST_NAME_ARG, (String) p.getTitle());
+ broadcast.putInt(
+ AudioStreamDetailsFragment.BROADCAST_ID_ARG, p.getAudioStreamBroadcastId());
+
+ new SubSettingLauncher(p.getContext())
+ .setTitleText(
+ p.getContext().getString(R.string.audio_streams_detail_page_title))
+ .setDestination(AudioStreamDetailsFragment.class.getName())
+ // TODO(chelseahao): Add logging enum
+ .setSourceMetricsCategory(SettingsEnums.PAGE_UNKNOWN)
+ .setArguments(broadcast)
+ .launch();
+ return true;
+ };
+ }
+
+ @Override
+ AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
+ return AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_ADDED;
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SyncedState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SyncedState.java
new file mode 100644
index 0000000..dffb235
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SyncedState.java
@@ -0,0 +1,114 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import android.app.AlertDialog;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+import androidx.preference.Preference;
+
+import com.android.settings.R;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.utils.ThreadUtils;
+
+import java.nio.charset.StandardCharsets;
+
+class SyncedState extends AudioStreamStateHandler {
+ private static final String TAG = "SyncedState";
+ private static final boolean DEBUG = BluetoothUtils.D;
+ @Nullable private static SyncedState sInstance = null;
+
+ SyncedState() {}
+
+ static SyncedState getInstance() {
+ if (sInstance == null) {
+ sInstance = new SyncedState();
+ }
+ return sInstance;
+ }
+
+ @Override
+ Preference.OnPreferenceClickListener getOnClickListener(
+ AudioStreamsProgressCategoryController controller) {
+ return p -> addSourceOrShowDialog(p, controller);
+ }
+
+ @Override
+ AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
+ return AudioStreamsProgressCategoryController.AudioStreamState.SYNCED;
+ }
+
+ private boolean addSourceOrShowDialog(
+ Preference preference, AudioStreamsProgressCategoryController controller) {
+ var p = (AudioStreamPreference) preference;
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "preferenceClicked(): attempt to join broadcast id : "
+ + p.getAudioStreamBroadcastId());
+ }
+ var source = p.getAudioStreamMetadata();
+ if (source != null) {
+ if (source.isEncrypted()) {
+ ThreadUtils.postOnMainThread(() -> launchPasswordDialog(source, p, controller));
+ } else {
+ controller.handleSourceAddRequest(p, source);
+ }
+ }
+ return true;
+ }
+
+ private void launchPasswordDialog(
+ BluetoothLeBroadcastMetadata source,
+ AudioStreamPreference preference,
+ AudioStreamsProgressCategoryController controller) {
+ View layout =
+ LayoutInflater.from(preference.getContext())
+ .inflate(R.layout.bluetooth_find_broadcast_password_dialog, null);
+ ((TextView) layout.requireViewById(R.id.broadcast_name_text))
+ .setText(preference.getTitle());
+ AlertDialog alertDialog =
+ new AlertDialog.Builder(preference.getContext())
+ .setTitle(R.string.find_broadcast_password_dialog_title)
+ .setView(layout)
+ .setNeutralButton(android.R.string.cancel, null)
+ .setPositiveButton(
+ R.string.bluetooth_connect_access_dialog_positive,
+ (dialog, which) -> {
+ var code =
+ ((EditText)
+ layout.requireViewById(
+ R.id.broadcast_edit_text))
+ .getText()
+ .toString();
+ var metadata =
+ new BluetoothLeBroadcastMetadata.Builder(source)
+ .setBroadcastCode(
+ code.getBytes(StandardCharsets.UTF_8))
+ .build();
+ controller.handleSourceAddRequest(preference, metadata);
+ })
+ .create();
+ alertDialog.show();
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncState.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncState.java
new file mode 100644
index 0000000..ac4d9a1
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncState.java
@@ -0,0 +1,117 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsScanQrCodeController.REQUEST_SCAN_BT_BROADCAST_QR_CODE;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.settings.R;
+import com.android.settings.connecteddevice.audiosharing.audiostreams.qrcode.QrCodeScanModeActivity;
+import com.android.settingslib.bluetooth.BluetoothBroadcastUtils;
+import com.android.settingslib.utils.ThreadUtils;
+
+class WaitForSyncState extends AudioStreamStateHandler {
+ @VisibleForTesting
+ static final int AUDIO_STREAM_WAIT_FOR_SYNC_STATE_SUMMARY =
+ R.string.audio_streams_wait_for_sync_state_summary;
+
+ @VisibleForTesting static final int WAIT_FOR_SYNC_TIMEOUT_MILLIS = 15000;
+
+ @Nullable private static WaitForSyncState sInstance = null;
+
+ private WaitForSyncState() {}
+
+ static WaitForSyncState getInstance() {
+ if (sInstance == null) {
+ sInstance = new WaitForSyncState();
+ }
+ return sInstance;
+ }
+
+ @Override
+ void performAction(
+ AudioStreamPreference preference,
+ AudioStreamsProgressCategoryController controller,
+ AudioStreamsHelper helper) {
+ var metadata = preference.getAudioStreamMetadata();
+ if (metadata != null) {
+ mHandler.postDelayed(
+ () -> {
+ if (preference.isShown()
+ && preference.getAudioStreamState() == getStateEnum()) {
+ controller.handleSourceLost(preference.getAudioStreamBroadcastId());
+ ThreadUtils.postOnMainThread(
+ () -> {
+ if (controller.getFragment() != null) {
+ AudioStreamsDialogFragment.show(
+ controller.getFragment(),
+ getBroadcastUnavailableDialog(
+ preference.getContext(),
+ AudioStreamsHelper.getBroadcastName(
+ metadata),
+ controller));
+ }
+ });
+ }
+ },
+ WAIT_FOR_SYNC_TIMEOUT_MILLIS);
+ }
+ }
+
+ @Override
+ int getSummary() {
+ return AUDIO_STREAM_WAIT_FOR_SYNC_STATE_SUMMARY;
+ }
+
+ @Override
+ AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
+ return AudioStreamsProgressCategoryController.AudioStreamState.WAIT_FOR_SYNC;
+ }
+
+ private AudioStreamsDialogFragment.DialogBuilder getBroadcastUnavailableDialog(
+ Context context,
+ String broadcastName,
+ AudioStreamsProgressCategoryController controller) {
+ return new AudioStreamsDialogFragment.DialogBuilder(context)
+ .setTitle(context.getString(R.string.audio_streams_dialog_stream_is_not_available))
+ .setSubTitle1(broadcastName)
+ .setSubTitle2(context.getString(R.string.audio_streams_is_not_playing))
+ .setLeftButtonText(context.getString(R.string.audio_streams_dialog_close))
+ .setLeftButtonOnClickListener(AlertDialog::dismiss)
+ .setRightButtonText(context.getString(R.string.audio_streams_dialog_retry))
+ .setRightButtonOnClickListener(
+ dialog -> {
+ if (controller.getFragment() != null) {
+ Intent intent = new Intent(context, QrCodeScanModeActivity.class);
+ intent.setAction(
+ BluetoothBroadcastUtils
+ .ACTION_BLUETOOTH_LE_AUDIO_QR_CODE_SCANNER);
+ controller
+ .getFragment()
+ .startActivityForResult(
+ intent, REQUEST_SCAN_BT_BROADCAST_QR_CODE);
+ dialog.dismiss();
+ }
+ });
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/qrcode/QrCodeScanModeActivity.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/qrcode/QrCodeScanModeActivity.java
new file mode 100644
index 0000000..aa460d3
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/qrcode/QrCodeScanModeActivity.java
@@ -0,0 +1,117 @@
+/*
+ * 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.audiostreams.qrcode;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.fragment.app.FragmentTransaction;
+
+import com.android.settings.R;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
+import com.android.settingslib.bluetooth.BluetoothBroadcastUtils;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+
+/**
+ * Finding a broadcast through QR code.
+ *
+ * <p>To use intent action {@link
+ * BluetoothBroadcastUtils#ACTION_BLUETOOTH_LE_AUDIO_QR_CODE_SCANNER}, specify the bluetooth device
+ * sink of the broadcast to be provisioned in {@link
+ * BluetoothBroadcastUtils#EXTRA_BLUETOOTH_DEVICE_SINK} and check the operation for all coordinated
+ * set members throughout one session or not by {@link
+ * BluetoothBroadcastUtils#EXTRA_BLUETOOTH_SINK_IS_GROUP}.
+ */
+public class QrCodeScanModeActivity extends QrCodeScanModeBaseActivity {
+ private static final boolean DEBUG = BluetoothUtils.D;
+ private static final String TAG = "QrCodeScanModeActivity";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ protected void handleIntent(Intent intent) {
+ if (!AudioSharingUtils.isFeatureEnabled()) {
+ finish();
+ }
+ String action = intent != null ? intent.getAction() : null;
+ if (DEBUG) {
+ Log.d(TAG, "handleIntent(), action = " + action);
+ }
+
+ if (action == null) {
+ finish();
+ return;
+ }
+
+ switch (action) {
+ case BluetoothBroadcastUtils.ACTION_BLUETOOTH_LE_AUDIO_QR_CODE_SCANNER:
+ showQrCodeScannerFragment(intent);
+ break;
+ default:
+ if (DEBUG) {
+ Log.e(TAG, "Launch with an invalid action");
+ }
+ finish();
+ }
+ }
+
+ protected void showQrCodeScannerFragment(Intent intent) {
+ if (intent == null) {
+ if (DEBUG) {
+ Log.d(TAG, "intent is null, can not get bluetooth information from intent.");
+ }
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(TAG, "showQrCodeScannerFragment");
+ }
+
+ if (DEBUG) {
+ Log.d(TAG, "get extra from intent");
+ }
+
+ QrCodeScanModeFragment fragment =
+ (QrCodeScanModeFragment)
+ mFragmentManager.findFragmentByTag(
+ BluetoothBroadcastUtils.TAG_FRAGMENT_QR_CODE_SCANNER);
+
+ if (fragment == null) {
+ fragment = new QrCodeScanModeFragment();
+ } else {
+ if (fragment.isVisible()) {
+ return;
+ }
+
+ // When the fragment in back stack but not on top of the stack, we can simply pop
+ // stack because current fragment transactions are arranged in an order
+ mFragmentManager.popBackStackImmediate();
+ return;
+ }
+ final FragmentTransaction fragmentTransaction = mFragmentManager.beginTransaction();
+
+ fragmentTransaction.replace(
+ R.id.fragment_container,
+ fragment,
+ BluetoothBroadcastUtils.TAG_FRAGMENT_QR_CODE_SCANNER);
+ fragmentTransaction.commit();
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/qrcode/QrCodeScanModeBaseActivity.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/qrcode/QrCodeScanModeBaseActivity.java
new file mode 100644
index 0000000..637014a
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/qrcode/QrCodeScanModeBaseActivity.java
@@ -0,0 +1,64 @@
+/*
+ * 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.audiostreams.qrcode;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.SystemProperties;
+
+import androidx.fragment.app.FragmentManager;
+
+import com.android.settings.R;
+import com.android.settingslib.core.lifecycle.ObservableActivity;
+
+import com.google.android.setupdesign.util.ThemeHelper;
+import com.google.android.setupdesign.util.ThemeResolver;
+
+public abstract class QrCodeScanModeBaseActivity extends ObservableActivity {
+
+ private static final String THEME_KEY = "setupwizard.theme";
+ private static final String THEME_DEFAULT_VALUE = "SudThemeGlifV3_DayNight";
+ protected FragmentManager mFragmentManager;
+
+ protected abstract void handleIntent(Intent intent);
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ int defaultTheme =
+ ThemeHelper.isSetupWizardDayNightEnabled(this)
+ ? com.google.android.setupdesign.R.style.SudThemeGlifV3_DayNight
+ : com.google.android.setupdesign.R.style.SudThemeGlifV3_Light;
+ ThemeResolver themeResolver =
+ new ThemeResolver.Builder(ThemeResolver.getDefault())
+ .setDefaultTheme(defaultTheme)
+ .setUseDayNight(true)
+ .build();
+ setTheme(
+ themeResolver.resolve(
+ SystemProperties.get(THEME_KEY, THEME_DEFAULT_VALUE),
+ /* suppressDayNight= */ !ThemeHelper.isSetupWizardDayNightEnabled(this)));
+
+ setContentView(R.layout.qrcode_scan_mode_activity);
+ mFragmentManager = getSupportFragmentManager();
+
+ if (savedInstanceState == null) {
+ handleIntent(getIntent());
+ }
+ }
+}
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/qrcode/QrCodeScanModeFragment.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/qrcode/QrCodeScanModeFragment.java
new file mode 100644
index 0000000..a00c29b
--- /dev/null
+++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/qrcode/QrCodeScanModeFragment.java
@@ -0,0 +1,283 @@
+/*
+ * 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.audiostreams.qrcode;
+
+import android.app.Activity;
+import android.app.settings.SettingsEnums;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Matrix;
+import android.graphics.Outline;
+import android.graphics.Rect;
+import android.graphics.SurfaceTexture;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
+import android.util.Log;
+import android.util.Size;
+import android.view.LayoutInflater;
+import android.view.TextureView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewOutlineProvider;
+import android.view.accessibility.AccessibilityEvent;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsHelper;
+import com.android.settings.core.InstrumentedFragment;
+import com.android.settingslib.bluetooth.BluetoothBroadcastUtils;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.qrcode.QrCamera;
+
+import java.time.Duration;
+
+public class QrCodeScanModeFragment extends InstrumentedFragment
+ implements TextureView.SurfaceTextureListener, QrCamera.ScannerCallback {
+ private static final boolean DEBUG = BluetoothUtils.D;
+ private static final String TAG = "QrCodeScanModeFragment";
+
+ /** Message sent to hide error message */
+ private static final int MESSAGE_HIDE_ERROR_MESSAGE = 1;
+
+ /** Message sent to show error message */
+ private static final int MESSAGE_SHOW_ERROR_MESSAGE = 2;
+
+ /** Message sent to broadcast QR code */
+ private static final int MESSAGE_SCAN_BROADCAST_SUCCESS = 3;
+
+ private static final long SHOW_ERROR_MESSAGE_INTERVAL = 10000;
+ private static final long SHOW_SUCCESS_SQUARE_INTERVAL = 1000;
+
+ private static final Duration VIBRATE_DURATION_QR_CODE_RECOGNITION = Duration.ofMillis(3);
+
+ public static final String KEY_BROADCAST_METADATA = "key_broadcast_metadata";
+
+ private LocalBluetoothManager mLocalBluetoothManager;
+ private int mCornerRadius;
+ @Nullable private String mBroadcastMetadata;
+ private Context mContext;
+ @Nullable private QrCamera mCamera;
+ private TextureView mTextureView;
+ private TextView mSummary;
+ private TextView mErrorMessage;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mContext = getContext();
+ mLocalBluetoothManager = Utils.getLocalBluetoothManager(mContext);
+ }
+
+ @Override
+ public final View onCreateView(
+ LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ return inflater.inflate(
+ R.layout.qrcode_scanner_fragment, container, /* attachToRoot */ false);
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ mTextureView = view.findViewById(R.id.preview_view);
+ mCornerRadius =
+ mContext.getResources()
+ .getDimensionPixelSize(R.dimen.audio_streams_qrcode_preview_radius);
+ mTextureView.setSurfaceTextureListener(this);
+ mTextureView.setOutlineProvider(
+ new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View view, Outline outline) {
+ outline.setRoundRect(
+ 0, 0, view.getWidth(), view.getHeight(), mCornerRadius);
+ }
+ });
+ mTextureView.setClipToOutline(true);
+ mErrorMessage = view.findViewById(R.id.error_message);
+
+ var device =
+ AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(
+ mLocalBluetoothManager);
+ mSummary = view.findViewById(android.R.id.summary);
+ if (mSummary != null && device.isPresent()) {
+ mSummary.setText(
+ getString(
+ R.string.audio_streams_main_page_qr_code_scanner_summary,
+ device.get().getName()));
+ }
+ }
+
+ private void initCamera(SurfaceTexture surface) {
+ // Check if the camera has already created.
+ if (mCamera == null) {
+ mCamera = new QrCamera(mContext, this);
+ mCamera.start(surface);
+ }
+ }
+
+ private void destroyCamera() {
+ if (mCamera != null) {
+ mCamera.stop();
+ mCamera = null;
+ }
+ }
+
+ @Override
+ public void onSurfaceTextureAvailable(@NonNull SurfaceTexture surface, int width, int height) {
+ initCamera(surface);
+ }
+
+ @Override
+ public void onSurfaceTextureSizeChanged(
+ @NonNull SurfaceTexture surface, int width, int height) {}
+
+ @Override
+ public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surface) {
+ destroyCamera();
+ return true;
+ }
+
+ @Override
+ public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surface) {}
+
+ @Override
+ public void handleSuccessfulResult(String qrCode) {
+ if (DEBUG) {
+ Log.d(TAG, "handleSuccessfulResult(), get the qr code string.");
+ }
+ mBroadcastMetadata = qrCode;
+ handleBtLeAudioScanner();
+ }
+
+ @Override
+ public void handleCameraFailure() {
+ destroyCamera();
+ }
+
+ @Override
+ public Size getViewSize() {
+ return new Size(mTextureView.getWidth(), mTextureView.getHeight());
+ }
+
+ @Override
+ public Rect getFramePosition(Size previewSize, int cameraOrientation) {
+ return new Rect(0, 0, previewSize.getHeight(), previewSize.getHeight());
+ }
+
+ @Override
+ public void setTransform(Matrix transform) {
+ mTextureView.setTransform(transform);
+ }
+
+ @Override
+ public boolean isValid(String qrCode) {
+ if (qrCode.startsWith(BluetoothBroadcastUtils.SCHEME_BT_BROADCAST_METADATA)) {
+ return true;
+ } else {
+ showErrorMessage(R.string.audio_streams_qr_code_is_not_valid_format);
+ return false;
+ }
+ }
+
+ protected boolean isDecodeTaskAlive() {
+ return mCamera != null && mCamera.isDecodeTaskAlive();
+ }
+
+ private final Handler mHandler =
+ new Handler() {
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case MESSAGE_HIDE_ERROR_MESSAGE:
+ mErrorMessage.setVisibility(View.INVISIBLE);
+ break;
+
+ case MESSAGE_SHOW_ERROR_MESSAGE:
+ final String errorMessage = (String) msg.obj;
+
+ mErrorMessage.setVisibility(View.VISIBLE);
+ mErrorMessage.setText(errorMessage);
+ mErrorMessage.sendAccessibilityEvent(
+ AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
+
+ // Cancel any pending messages to hide error view and requeue the
+ // message so
+ // user has time to see error
+ removeMessages(MESSAGE_HIDE_ERROR_MESSAGE);
+ sendEmptyMessageDelayed(
+ MESSAGE_HIDE_ERROR_MESSAGE, SHOW_ERROR_MESSAGE_INTERVAL);
+ break;
+
+ case MESSAGE_SCAN_BROADCAST_SUCCESS:
+ Log.d(TAG, "scan success");
+ final Intent resultIntent = new Intent();
+ resultIntent.putExtra(KEY_BROADCAST_METADATA, mBroadcastMetadata);
+ getActivity().setResult(Activity.RESULT_OK, resultIntent);
+ notifyUserForQrCodeRecognition();
+ break;
+ default:
+ }
+ }
+ };
+
+ private void notifyUserForQrCodeRecognition() {
+ if (mCamera != null) {
+ mCamera.stop();
+ }
+
+ mErrorMessage.setVisibility(View.INVISIBLE);
+ mTextureView.setVisibility(View.INVISIBLE);
+
+ triggerVibrationForQrCodeRecognition(getContext());
+
+ getActivity().finish();
+ }
+
+ private static void triggerVibrationForQrCodeRecognition(Context context) {
+ Vibrator vibrator = context.getSystemService(Vibrator.class);
+ if (vibrator == null) {
+ return;
+ }
+ vibrator.vibrate(
+ VibrationEffect.createOneShot(
+ VIBRATE_DURATION_QR_CODE_RECOGNITION.toMillis(),
+ VibrationEffect.DEFAULT_AMPLITUDE));
+ }
+
+ private void showErrorMessage(@StringRes int messageResId) {
+ final Message message =
+ mHandler.obtainMessage(MESSAGE_SHOW_ERROR_MESSAGE, getString(messageResId));
+ message.sendToTarget();
+ }
+
+ private void handleBtLeAudioScanner() {
+ Message message = mHandler.obtainMessage(MESSAGE_SCAN_BROADCAST_SUCCESS);
+ mHandler.sendMessageDelayed(message, SHOW_SUCCESS_SQUARE_INTERVAL);
+ }
+
+ @Override
+ public int getMetricsCategory() {
+ return SettingsEnums.LE_AUDIO_BROADCAST_SCAN_QR_CODE;
+ }
+}
diff --git a/src/com/android/settings/overlay/FeatureFactory.kt b/src/com/android/settings/overlay/FeatureFactory.kt
index ef63f19..675d789 100644
--- a/src/com/android/settings/overlay/FeatureFactory.kt
+++ b/src/com/android/settings/overlay/FeatureFactory.kt
@@ -24,7 +24,6 @@
import com.android.settings.biometrics.fingerprint.FingerprintFeatureProvider
import com.android.settings.biometrics2.factory.BiometricsRepositoryProvider
import com.android.settings.bluetooth.BluetoothFeatureProvider
-import com.android.settings.connecteddevice.audiosharing.AudioSharingFeatureProvider
import com.android.settings.connecteddevice.fastpair.FastPairFeatureProvider
import com.android.settings.connecteddevice.stylus.StylusFeatureProvider
import com.android.settings.dashboard.DashboardFeatureProvider
@@ -185,11 +184,6 @@
abstract val displayFeatureProvider: DisplayFeatureProvider
/**
- * Gets implementation for audio sharing related feature.
- */
- abstract val audioSharingFeatureProvider: AudioSharingFeatureProvider
-
- /**
* Gets implementation for sync across devices related feature.
*/
abstract val syncAcrossDevicesFeatureProvider: SyncAcrossDevicesFeatureProvider
diff --git a/src/com/android/settings/overlay/FeatureFactoryImpl.kt b/src/com/android/settings/overlay/FeatureFactoryImpl.kt
index c74260c..2142ea5 100644
--- a/src/com/android/settings/overlay/FeatureFactoryImpl.kt
+++ b/src/com/android/settings/overlay/FeatureFactoryImpl.kt
@@ -34,8 +34,6 @@
import com.android.settings.biometrics2.factory.BiometricsRepositoryProviderImpl
import com.android.settings.bluetooth.BluetoothFeatureProvider
import com.android.settings.bluetooth.BluetoothFeatureProviderImpl
-import com.android.settings.connecteddevice.audiosharing.AudioSharingFeatureProvider
-import com.android.settings.connecteddevice.audiosharing.AudioSharingFeatureProviderImpl
import com.android.settings.connecteddevice.dock.DockUpdaterFeatureProviderImpl
import com.android.settings.connecteddevice.fastpair.FastPairFeatureProvider
import com.android.settings.connecteddevice.fastpair.FastPairFeatureProviderImpl
@@ -196,10 +194,6 @@
DisplayFeatureProviderImpl()
}
- override val audioSharingFeatureProvider: AudioSharingFeatureProvider by lazy {
- AudioSharingFeatureProviderImpl()
- }
-
override val syncAcrossDevicesFeatureProvider: SyncAcrossDevicesFeatureProvider by lazy {
SyncAcrossDevicesFeatureProviderImpl()
}
diff --git a/tests/robotests/src/com/android/settings/bluetooth/AvailableMediaBluetoothDeviceUpdaterTest.java b/tests/robotests/src/com/android/settings/bluetooth/AvailableMediaBluetoothDeviceUpdaterTest.java
index 6aa2831..fc19728 100644
--- a/tests/robotests/src/com/android/settings/bluetooth/AvailableMediaBluetoothDeviceUpdaterTest.java
+++ b/tests/robotests/src/com/android/settings/bluetooth/AvailableMediaBluetoothDeviceUpdaterTest.java
@@ -24,26 +24,32 @@
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothStatusCodes;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.media.AudioManager;
+import android.platform.test.flag.junit.SetFlagsRule;
import android.util.Pair;
import com.android.settings.connecteddevice.DevicePreferenceCallback;
-import com.android.settings.connecteddevice.audiosharing.AudioSharingFeatureProvider;
import com.android.settings.dashboard.DashboardFragment;
-import com.android.settings.testutils.FakeFeatureFactory;
import com.android.settings.testutils.shadow.ShadowAudioManager;
import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.flags.Flags;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
@@ -55,6 +61,7 @@
import java.util.ArrayList;
import java.util.Collection;
+import java.util.List;
@RunWith(RobolectricTestRunner.class)
@Config(
@@ -66,6 +73,8 @@
public class AvailableMediaBluetoothDeviceUpdaterTest {
private static final String MAC_ADDRESS = "04:52:C7:0B:D8:3C";
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
@Mock private DashboardFragment mDashboardFragment;
@Mock private DevicePreferenceCallback mDevicePreferenceCallback;
@Mock private CachedBluetoothDevice mCachedBluetoothDevice;
@@ -73,6 +82,9 @@
@Mock private Drawable mDrawable;
@Mock private LocalBluetoothManager mLocalBtManager;
@Mock private CachedBluetoothDeviceManager mCachedDeviceManager;
+ @Mock private LocalBluetoothProfileManager mProfileManager;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Mock private BluetoothLeBroadcastReceiveState mBroadcastReceiveState;
private Context mContext;
private AvailableMediaBluetoothDeviceUpdater mBluetoothDeviceUpdater;
@@ -80,20 +92,24 @@
private AudioManager mAudioManager;
private BluetoothDevicePreference mPreference;
private ShadowBluetoothAdapter mShadowBluetoothAdapter;
- private AudioSharingFeatureProvider mFeatureProvider;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mContext = RuntimeEnvironment.application;
- mFeatureProvider = FakeFeatureFactory.setupForTest().getAudioSharingFeatureProvider();
mAudioManager = mContext.getSystemService(AudioManager.class);
ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
mLocalBtManager = Utils.getLocalBtManager(mContext);
+ when(mProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ when(mLocalBtManager.getProfileManager()).thenReturn(mProfileManager);
when(mLocalBtManager.getCachedDeviceManager()).thenReturn(mCachedDeviceManager);
mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
mCachedDevices = new ArrayList<>();
mCachedDevices.add(mCachedBluetoothDevice);
when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(mCachedDevices);
@@ -252,14 +268,16 @@
@Test
public void
- onProfileConnectionStateChanged_leaDeviceConnected_notInCallNoSharing_addsPreference() {
+ onProfileConnectionStateChanged_leaConnected_notInCallSharingFlagOff_addsPreference() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
mAudioManager.setMode(AudioManager.MODE_NORMAL);
when(mBluetoothDeviceUpdater.isDeviceConnected(any(CachedBluetoothDevice.class)))
.thenReturn(true);
when(mCachedBluetoothDevice.isConnectedLeAudioDevice()).thenReturn(true);
- when(mFeatureProvider.isAudioSharingFilterMatched(
- any(CachedBluetoothDevice.class), any(LocalBluetoothManager.class)))
- .thenReturn(false);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mBroadcastReceiveState));
+ List<Long> bisSyncState = new ArrayList<>();
+ bisSyncState.add(1L);
+ when(mBroadcastReceiveState.getBisSyncState()).thenReturn(bisSyncState);
mBluetoothDeviceUpdater.onProfileConnectionStateChanged(
mCachedBluetoothDevice,
@@ -271,14 +289,50 @@
@Test
public void
- onProfileConnectionStateChanged_leaDeviceConnected_inCallNoSharing_addsPreference() {
+ onProfileConnectionStateChanged_leaConnected_notInCallNotInSharing_addsPreference() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mAudioManager.setMode(AudioManager.MODE_NORMAL);
+ when(mBluetoothDeviceUpdater.isDeviceConnected(any(CachedBluetoothDevice.class)))
+ .thenReturn(true);
+ when(mCachedBluetoothDevice.isConnectedLeAudioDevice()).thenReturn(true);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of());
+
+ mBluetoothDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+
+ verify(mBluetoothDeviceUpdater).addPreference(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaConnected_inCallSharingFlagOff_addsPreference() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
mAudioManager.setMode(AudioManager.MODE_IN_CALL);
when(mBluetoothDeviceUpdater.isDeviceConnected(any(CachedBluetoothDevice.class)))
.thenReturn(true);
when(mCachedBluetoothDevice.isConnectedLeAudioDevice()).thenReturn(true);
- when(mFeatureProvider.isAudioSharingFilterMatched(
- any(CachedBluetoothDevice.class), any(LocalBluetoothManager.class)))
- .thenReturn(false);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mBroadcastReceiveState));
+ List<Long> bisSyncState = new ArrayList<>();
+ bisSyncState.add(1L);
+ when(mBroadcastReceiveState.getBisSyncState()).thenReturn(bisSyncState);
+
+ mBluetoothDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+
+ verify(mBluetoothDeviceUpdater).addPreference(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaConnected_inCallNotInSharing_addsPreference() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mAudioManager.setMode(AudioManager.MODE_IN_CALL);
+ when(mBluetoothDeviceUpdater.isDeviceConnected(any(CachedBluetoothDevice.class)))
+ .thenReturn(true);
+ when(mCachedBluetoothDevice.isConnectedLeAudioDevice()).thenReturn(true);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of());
mBluetoothDeviceUpdater.onProfileConnectionStateChanged(
mCachedBluetoothDevice,
@@ -291,14 +345,16 @@
@Test
public void
onProfileConnectionStateChanged_leaDeviceConnected_notInCallInSharing_removesPref() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
mAudioManager.setMode(AudioManager.MODE_NORMAL);
when(mBluetoothDeviceUpdater.isDeviceConnected(any(CachedBluetoothDevice.class)))
.thenReturn(true);
when(mCachedBluetoothDevice.isConnectedLeAudioDevice()).thenReturn(true);
when(mCachedBluetoothDevice.isConnectedA2dpDevice()).thenReturn(true);
- when(mFeatureProvider.isAudioSharingFilterMatched(
- any(CachedBluetoothDevice.class), any(LocalBluetoothManager.class)))
- .thenReturn(true);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mBroadcastReceiveState));
+ List<Long> bisSyncState = new ArrayList<>();
+ bisSyncState.add(1L);
+ when(mBroadcastReceiveState.getBisSyncState()).thenReturn(bisSyncState);
mBluetoothDeviceUpdater.onProfileConnectionStateChanged(
mCachedBluetoothDevice,
@@ -310,14 +366,16 @@
@Test
public void onProfileConnectionStateChanged_leaDeviceConnected_inCallInSharing_removesPref() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
mAudioManager.setMode(AudioManager.MODE_NORMAL);
when(mBluetoothDeviceUpdater.isDeviceConnected(any(CachedBluetoothDevice.class)))
.thenReturn(true);
when(mCachedBluetoothDevice.isConnectedLeAudioDevice()).thenReturn(true);
when(mCachedBluetoothDevice.isConnectedHfpDevice()).thenReturn(true);
- when(mFeatureProvider.isAudioSharingFilterMatched(
- any(CachedBluetoothDevice.class), any(LocalBluetoothManager.class)))
- .thenReturn(true);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mBroadcastReceiveState));
+ List<Long> bisSyncState = new ArrayList<>();
+ bisSyncState.add(1L);
+ when(mBroadcastReceiveState.getBisSyncState()).thenReturn(bisSyncState);
mBluetoothDeviceUpdater.onProfileConnectionStateChanged(
mCachedBluetoothDevice,
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/AvailableMediaDeviceGroupControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/AvailableMediaDeviceGroupControllerTest.java
index 8f07cca..211817a 100644
--- a/tests/robotests/src/com/android/settings/connecteddevice/AvailableMediaDeviceGroupControllerTest.java
+++ b/tests/robotests/src/com/android/settings/connecteddevice/AvailableMediaDeviceGroupControllerTest.java
@@ -22,18 +22,24 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothStatusCodes;
import android.content.Context;
import android.content.pm.PackageManager;
import android.graphics.drawable.Drawable;
import android.media.AudioManager;
+import android.platform.test.flag.junit.SetFlagsRule;
import android.util.Pair;
import androidx.appcompat.app.AlertDialog;
@@ -48,16 +54,21 @@
import com.android.settings.bluetooth.AvailableMediaBluetoothDeviceUpdater;
import com.android.settings.bluetooth.BluetoothDevicePreference;
import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.audiosharing.AudioSharingDialogHandler;
import com.android.settings.testutils.shadow.ShadowAlertDialogCompat;
import com.android.settings.testutils.shadow.ShadowAudioManager;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.BluetoothEventManager;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
import com.android.settingslib.bluetooth.HearingAidInfo;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.flags.Flags;
import org.junit.Before;
import org.junit.Rule;
@@ -71,17 +82,22 @@
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+import java.util.concurrent.Executor;
/** Tests for {@link AvailableMediaDeviceGroupController}. */
@RunWith(RobolectricTestRunner.class)
@Config(
shadows = {
ShadowAudioManager.class,
+ ShadowBluetoothAdapter.class,
ShadowBluetoothUtils.class,
ShadowAlertDialogCompat.class,
})
public class AvailableMediaDeviceGroupControllerTest {
@Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
private static final String TEST_DEVICE_ADDRESS = "00:A1:A1:A1:A1:A1";
private static final String PREFERENCE_KEY_1 = "pref_key_1";
@@ -96,17 +112,20 @@
@Mock private PackageManager mPackageManager;
@Mock private BluetoothEventManager mEventManager;
@Mock private LocalBluetoothManager mLocalBluetoothManager;
+ @Mock private LocalBluetoothProfileManager mLocalBtProfileManager;
@Mock private CachedBluetoothDeviceManager mCachedDeviceManager;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
@Mock private CachedBluetoothDevice mCachedBluetoothDevice;
@Mock private BluetoothDevice mDevice;
- @Mock
- private Drawable mDrawable;
+ @Mock private Drawable mDrawable;
+ @Mock private AudioSharingDialogHandler mDialogHandler;
private PreferenceGroup mPreferenceGroup;
private Context mContext;
private Preference mPreference;
private AvailableMediaDeviceGroupController mAvailableMediaDeviceGroupController;
private AudioManager mAudioManager;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
private LifecycleOwner mLifecycleOwner;
private Lifecycle mLifecycle;
@@ -123,19 +142,27 @@
doReturn(mPackageManager).when(mContext).getPackageManager();
doReturn(true).when(mPackageManager).hasSystemFeature(PackageManager.FEATURE_BLUETOOTH);
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_NOT_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_NOT_SUPPORTED);
ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBluetoothManager;
mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
mAudioManager = mContext.getSystemService(AudioManager.class);
doReturn(mEventManager).when(mLocalBluetoothManager).getEventManager();
+ when(mLocalBluetoothManager.getProfileManager()).thenReturn(mLocalBtProfileManager);
when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(mCachedDeviceManager);
when(mCachedDeviceManager.findDevice(any(BluetoothDevice.class)))
.thenReturn(mCachedBluetoothDevice);
when(mCachedBluetoothDevice.getAddress()).thenReturn(TEST_DEVICE_ADDRESS);
mAvailableMediaDeviceGroupController =
- spy(new AvailableMediaDeviceGroupController(mContext, null, mLifecycle));
+ spy(new AvailableMediaDeviceGroupController(mContext));
mAvailableMediaDeviceGroupController.setBluetoothDeviceUpdater(
mAvailableMediaBluetoothDeviceUpdater);
+ mAvailableMediaDeviceGroupController.setDialogHandler(mDialogHandler);
mAvailableMediaDeviceGroupController.setFragmentManager(
mActivity.getSupportFragmentManager());
mAvailableMediaDeviceGroupController.mPreferenceGroup = mPreferenceGroup;
@@ -181,23 +208,58 @@
}
@Test
- public void testRegister() {
+ public void testRegister_audioSharingOff() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
// register the callback in onStart()
mAvailableMediaDeviceGroupController.onStart(mLifecycleOwner);
verify(mAvailableMediaBluetoothDeviceUpdater).registerCallback();
- verify(mLocalBluetoothManager.getEventManager())
- .registerCallback(any(BluetoothCallback.class));
+ verify(mEventManager).registerCallback(any(BluetoothCallback.class));
verify(mAvailableMediaBluetoothDeviceUpdater).refreshPreference();
+ verify(mAssistant, times(0))
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mDialogHandler, times(0)).registerCallbacks(any(Executor.class));
}
@Test
- public void testUnregister() {
+ public void testRegister_audioSharingOn() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ setUpBroadcast();
+ // register the callback in onStart()
+ mAvailableMediaDeviceGroupController.onStart(mLifecycleOwner);
+ verify(mAvailableMediaBluetoothDeviceUpdater).registerCallback();
+ verify(mEventManager).registerCallback(any(BluetoothCallback.class));
+ verify(mAvailableMediaBluetoothDeviceUpdater).refreshPreference();
+ verify(mAssistant)
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mDialogHandler).registerCallbacks(any(Executor.class));
+ }
+
+ @Test
+ public void testUnregister_audioSharingOff() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
// unregister the callback in onStop()
mAvailableMediaDeviceGroupController.onStop(mLifecycleOwner);
verify(mAvailableMediaBluetoothDeviceUpdater).unregisterCallback();
- verify(mLocalBluetoothManager.getEventManager())
- .unregisterCallback(any(BluetoothCallback.class));
+ verify(mEventManager).unregisterCallback(any(BluetoothCallback.class));
+ verify(mAssistant, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mDialogHandler, times(0)).unregisterCallbacks();
+ }
+
+ @Test
+ public void testUnregister_audioSharingOn() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ setUpBroadcast();
+ // unregister the callback in onStop()
+ mAvailableMediaDeviceGroupController.onStop(mLifecycleOwner);
+ verify(mAvailableMediaBluetoothDeviceUpdater).unregisterCallback();
+ verify(mEventManager).unregisterCallback(any(BluetoothCallback.class));
+ verify(mAssistant)
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mDialogHandler).unregisterCallbacks();
}
@Test
@@ -267,7 +329,8 @@
}
@Test
- public void onDeviceClick_setActive() {
+ public void onDeviceClick_audioSharingOff_setActive() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
when(mCachedBluetoothDevice.getDevice()).thenReturn(mDevice);
Pair<Drawable, String> pair = new Pair<>(mDrawable, TEST_DEVICE_NAME);
when(mCachedBluetoothDevice.getDrawableWithDescription()).thenReturn(pair);
@@ -280,4 +343,37 @@
mAvailableMediaDeviceGroupController.onDeviceClick(preference);
verify(mCachedBluetoothDevice).setActive();
}
+
+ @Test
+ public void onDeviceClick_audioSharingOn_dialogHandler() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ setUpBroadcast();
+ when(mCachedBluetoothDevice.getDevice()).thenReturn(mDevice);
+ Pair<Drawable, String> pair = new Pair<>(mDrawable, TEST_DEVICE_NAME);
+ when(mCachedBluetoothDevice.getDrawableWithDescription()).thenReturn(pair);
+ BluetoothDevicePreference preference =
+ new BluetoothDevicePreference(
+ mContext,
+ mCachedBluetoothDevice,
+ true,
+ BluetoothDevicePreference.SortType.TYPE_NO_SORT);
+ mAvailableMediaDeviceGroupController.onDeviceClick(preference);
+ verify(mDialogHandler)
+ .handleDeviceConnected(mCachedBluetoothDevice, /* userTriggered= */ true);
+ }
+
+ private void setUpBroadcast() {
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ when(mLocalBtProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ doNothing()
+ .when(mAssistant)
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ doNothing()
+ .when(mAssistant)
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ }
}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceDashboardFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceDashboardFragmentTest.java
index 0cd464c..33292af 100644
--- a/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceDashboardFragmentTest.java
+++ b/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceDashboardFragmentTest.java
@@ -72,6 +72,9 @@
private static final String KEY_DISCOVERABLE_FOOTER = "discoverable_footer";
private static final String KEY_SAVED_DEVICE_SEE_ALL = "previously_connected_devices_see_all";
private static final String KEY_FAST_PAIR_DEVICE_SEE_ALL = "fast_pair_devices_see_all";
+ private static final String KEY_AUDIO_SHARING_DEVICES = "audio_sharing_device_list";
+ private static final String KEY_AUDIO_SHARING_SETTINGS =
+ "connected_device_audio_sharing_settings";
private static final String KEY_ADD_BT_DEVICES = "add_bt_devices";
private static final String SETTINGS_PACKAGE_NAME = "com.android.settings";
private static final String SYSTEMUI_PACKAGE_NAME = "com.android.systemui";
@@ -84,7 +87,6 @@
private Context mContext;
private ConnectedDeviceDashboardFragment mFragment;
private FakeFeatureFactory mFeatureFactory;
- private AvailableMediaDeviceGroupController mMediaDeviceGroupController;
@Before
public void setUp() {
@@ -93,21 +95,13 @@
mContext = spy(RuntimeEnvironment.application);
mFragment = new ConnectedDeviceDashboardFragment();
mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_SUBSEQUENT_PAIR_SETTINGS_INTEGRATION);
+ mSetFlagsRule.enableFlags(com.android.settingslib.flags.Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
mFeatureFactory = FakeFeatureFactory.setupForTest();
when(mFeatureFactory
.getFastPairFeatureProvider()
.getFastPairDeviceUpdater(
any(Context.class), any(DevicePreferenceCallback.class)))
.thenReturn(mFastPairDeviceUpdater);
- when(mFeatureFactory
- .getAudioSharingFeatureProvider()
- .createAudioSharingDevicePreferenceController(mContext, null, null))
- .thenReturn(null);
- mMediaDeviceGroupController = new AvailableMediaDeviceGroupController(mContext, null, null);
- when(mFeatureFactory
- .getAudioSharingFeatureProvider()
- .createAvailableMediaDeviceGroupController(mContext, null, null))
- .thenReturn(mMediaDeviceGroupController);
doReturn(mPackageManager).when(mContext).getPackageManager();
doReturn(true).when(mPackageManager).hasSystemFeature(PackageManager.FEATURE_BLUETOOTH);
}
@@ -135,7 +129,9 @@
KEY_NEARBY_DEVICES,
KEY_DISCOVERABLE_FOOTER,
KEY_SAVED_DEVICE_SEE_ALL,
- KEY_FAST_PAIR_DEVICE_SEE_ALL);
+ KEY_FAST_PAIR_DEVICE_SEE_ALL,
+ KEY_AUDIO_SHARING_DEVICES,
+ KEY_AUDIO_SHARING_SETTINGS);
}
@Test
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingActivityTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingActivityTest.java
new file mode 100644
index 0000000..0dddec9
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingActivityTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothStatusCodes;
+import android.os.Bundle;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settingslib.flags.Flags;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {ShadowBluetoothAdapter.class})
+public class AudioSharingActivityTest {
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ private AudioSharingActivity mActivity;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+
+ @Before
+ public void setUp() {
+ mActivity = spy(Robolectric.buildActivity(AudioSharingActivity.class).get());
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ }
+
+ @Test
+ public void isValidFragment_returnsTrue() {
+ assertThat(mActivity.isValidFragment(AudioSharingDashboardFragment.class.getName()))
+ .isTrue();
+ }
+
+ @Test
+ public void isValidFragment_returnsFalse() {
+ assertThat(mActivity.isValidFragment("")).isFalse();
+ }
+
+ @Test
+ public void onCreate_flagOff_finish() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mActivity.onCreate(new Bundle());
+ verify(mActivity).finish();
+ }
+
+ @Test
+ public void onCreate_flagOn_create() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mActivity.onCreate(new Bundle());
+ verify(mActivity, times(0)).finish();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingBluetoothDeviceUpdaterTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingBluetoothDeviceUpdaterTest.java
new file mode 100644
index 0000000..f1c3126
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingBluetoothDeviceUpdaterTest.java
@@ -0,0 +1,270 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.os.Looper;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.util.Pair;
+
+import androidx.preference.Preference;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.bluetooth.BluetoothDevicePreference;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+import com.android.settings.testutils.shadow.ShadowThreadUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.flags.Flags;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowBluetoothAdapter.class,
+ ShadowBluetoothUtils.class,
+ ShadowThreadUtils.class
+ })
+public class AudioSharingBluetoothDeviceUpdaterTest {
+ private static final String MAC_ADDRESS = "04:52:C7:0B:D8:3C";
+ private static final String TEST_DEVICE_NAME = "test";
+ private static final String PREF_KEY = "audio_sharing_bt";
+ private static final String TAG = "AudioSharingBluetoothDeviceUpdater";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ @Mock private DevicePreferenceCallback mDevicePreferenceCallback;
+ @Mock private CachedBluetoothDevice mCachedBluetoothDevice;
+ @Mock private BluetoothDevice mBluetoothDevice;
+ @Mock private Drawable mDrawable;
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private CachedBluetoothDeviceManager mCachedDeviceManager;
+ @Mock private LocalBluetoothProfileManager mLocalBtProfileManager;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Mock private BluetoothLeBroadcastReceiveState mState;
+
+ private Context mContext;
+ private AudioSharingBluetoothDeviceUpdater mDeviceUpdater;
+ private Collection<CachedBluetoothDevice> mCachedDevices;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+
+ @Before
+ public void setUp() {
+ mContext = ApplicationProvider.getApplicationContext();
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mLocalBtManager = Utils.getLocalBtManager(mContext);
+ when(mLocalBtManager.getCachedDeviceManager()).thenReturn(mCachedDeviceManager);
+ when(mLocalBtManager.getProfileManager()).thenReturn(mLocalBtProfileManager);
+ when(mLocalBtProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ List<Long> bisSyncState = new ArrayList<>();
+ bisSyncState.add(1L);
+ when(mState.getBisSyncState()).thenReturn(bisSyncState);
+ Pair<Drawable, String> pairs = new Pair<>(mDrawable, TEST_DEVICE_NAME);
+ doReturn(TEST_DEVICE_NAME).when(mCachedBluetoothDevice).getName();
+ doReturn(mBluetoothDevice).when(mCachedBluetoothDevice).getDevice();
+ doReturn(MAC_ADDRESS).when(mCachedBluetoothDevice).getAddress();
+ doReturn(pairs).when(mCachedBluetoothDevice).getDrawableWithDescription();
+ doReturn(ImmutableSet.of()).when(mCachedBluetoothDevice).getMemberDevice();
+ doReturn("").when(mCachedBluetoothDevice).getConnectionSummary();
+ mCachedDevices = new ArrayList<>();
+ mCachedDevices.add(mCachedBluetoothDevice);
+ when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(mCachedDevices);
+ doNothing().when(mDevicePreferenceCallback).onDeviceAdded(any(Preference.class));
+ doNothing().when(mDevicePreferenceCallback).onDeviceRemoved(any(Preference.class));
+ mDeviceUpdater =
+ spy(
+ new AudioSharingBluetoothDeviceUpdater(
+ mContext, mDevicePreferenceCallback, /* metricsCategory= */ 0));
+ mDeviceUpdater.setPrefContext(mContext);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceConnected_flagOff_removesPref() {
+ setupPreferenceMapWithDevice();
+
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mDevicePreferenceCallback).onDeviceRemoved(captor.capture());
+ assertThat(captor.getValue() instanceof BluetoothDevicePreference).isTrue();
+ assertThat(((BluetoothDevicePreference) captor.getValue()).getBluetoothDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceConnected_noSource_removesPref() {
+ setupPreferenceMapWithDevice();
+
+ when(mAssistant.getAllSources(mBluetoothDevice)).thenReturn(ImmutableList.of());
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mDevicePreferenceCallback).onDeviceRemoved(captor.capture());
+ assertThat(captor.getValue() instanceof BluetoothDevicePreference).isTrue();
+ assertThat(((BluetoothDevicePreference) captor.getValue()).getBluetoothDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_deviceIsNotInList_removesPref() {
+ setupPreferenceMapWithDevice();
+
+ mCachedDevices.clear();
+ when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(mCachedDevices);
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mDevicePreferenceCallback).onDeviceRemoved(captor.capture());
+ assertThat(captor.getValue() instanceof BluetoothDevicePreference).isTrue();
+ assertThat(((BluetoothDevicePreference) captor.getValue()).getBluetoothDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceDisconnected_removesPref() {
+ setupPreferenceMapWithDevice();
+
+ when(mDeviceUpdater.isDeviceConnected(any(CachedBluetoothDevice.class))).thenReturn(false);
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_DISCONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mDevicePreferenceCallback).onDeviceRemoved(captor.capture());
+ assertThat(captor.getValue() instanceof BluetoothDevicePreference).isTrue();
+ assertThat(((BluetoothDevicePreference) captor.getValue()).getBluetoothDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceDisconnecting_removesPref() {
+ setupPreferenceMapWithDevice();
+ doReturn(false).when(mCachedBluetoothDevice).isConnectedLeAudioDevice();
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mDevicePreferenceCallback).onDeviceRemoved(captor.capture());
+ assertThat(captor.getValue() instanceof BluetoothDevicePreference).isTrue();
+ assertThat(((BluetoothDevicePreference) captor.getValue()).getBluetoothDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceConnected_hasSource_addsPreference() {
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+ setupPreferenceMapWithDevice();
+
+ verify(mDevicePreferenceCallback).onDeviceAdded(captor.capture());
+ assertThat(captor.getValue() instanceof BluetoothDevicePreference).isTrue();
+ assertThat(((BluetoothDevicePreference) captor.getValue()).getBluetoothDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void getLogTag_returnsCorrectTag() {
+ assertThat(mDeviceUpdater.getLogTag()).isEqualTo(TAG);
+ }
+
+ @Test
+ public void getPreferenceKey_returnsCorrectKey() {
+ assertThat(mDeviceUpdater.getPreferenceKey()).isEqualTo(PREF_KEY);
+ }
+
+ private void setupPreferenceMapWithDevice() {
+ // Add device to preferenceMap
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mAssistant.getAllSources(mBluetoothDevice)).thenReturn(ImmutableList.of(mState));
+ when(mDeviceUpdater.isDeviceConnected(any(CachedBluetoothDevice.class))).thenReturn(true);
+ doReturn(true).when(mCachedBluetoothDevice).isConnectedLeAudioDevice();
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceControllerTest.java
new file mode 100644
index 0000000..a395716
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceControllerTest.java
@@ -0,0 +1,271 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.android.settings.core.BasePreferenceController.AVAILABLE;
+import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothLeBroadcast;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.Context;
+import android.os.Looper;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.PreferenceScreen;
+import androidx.preference.TwoStatePreference;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+import com.android.settings.testutils.shadow.ShadowThreadUtils;
+import com.android.settingslib.bluetooth.BluetoothEventManager;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.flags.Flags;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+import java.util.concurrent.Executor;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowBluetoothAdapter.class,
+ ShadowBluetoothUtils.class,
+ ShadowThreadUtils.class,
+ })
+public class AudioSharingCompatibilityPreferenceControllerTest {
+ private static final String PREF_KEY = "audio_sharing_stream_compatibility";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+ @Spy Context mContext = ApplicationProvider.getApplicationContext();
+ @Mock private PreferenceScreen mScreen;
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private BluetoothEventManager mBtEventManager;
+ @Mock private LocalBluetoothProfileManager mBtProfileManager;
+ @Mock private LocalBluetoothLeBroadcast mBroadcast;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Mock private VolumeControlProfile mVolumeControl;
+ @Mock private TwoStatePreference mPreference;
+ private AudioSharingCompatibilityPreferenceController mController;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private LocalBluetoothManager mLocalBluetoothManager;
+ private Lifecycle mLifecycle;
+ private LifecycleOwner mLifecycleOwner;
+
+ @Before
+ public void setUp() {
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mLifecycleOwner = () -> mLifecycle;
+ mLifecycle = new Lifecycle(mLifecycleOwner);
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
+ when(mLocalBluetoothManager.getEventManager()).thenReturn(mBtEventManager);
+ when(mLocalBluetoothManager.getProfileManager()).thenReturn(mBtProfileManager);
+ when(mBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast);
+ when(mBtProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ when(mBtProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControl);
+ when(mBroadcast.isProfileReady()).thenReturn(true);
+ when(mAssistant.isProfileReady()).thenReturn(true);
+ when(mVolumeControl.isProfileReady()).thenReturn(true);
+ mController = new AudioSharingCompatibilityPreferenceController(mContext, PREF_KEY);
+ when(mScreen.findPreference(PREF_KEY)).thenReturn(mPreference);
+ }
+
+ @Test
+ public void onStart_flagOn_registerCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mBroadcast)
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcast.Callback.class));
+ verify(mBtProfileManager, times(0)).addServiceListener(mController);
+ }
+
+ @Test
+ public void onStart_flagOnProfileNotReady_registerProfileCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isProfileReady()).thenReturn(false);
+ mController.onStart(mLifecycleOwner);
+ verify(mBroadcast, times(0))
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcast.Callback.class));
+ verify(mBtProfileManager).addServiceListener(mController);
+ }
+
+ @Test
+ public void onStart_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mBroadcast, times(0))
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcast.Callback.class));
+ }
+
+ @Test
+ public void onStop_flagOn_unregisterCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(true);
+ mController.onStop(mLifecycleOwner);
+ verify(mBroadcast).unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class));
+ verify(mBtProfileManager).removeServiceListener(mController);
+ }
+
+ @Test
+ public void onStop_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(true);
+ mController.onStop(mLifecycleOwner);
+ verify(mBroadcast, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class));
+ verify(mBtProfileManager, times(0)).removeServiceListener(mController);
+ }
+
+ @Test
+ public void onServiceConnected_updateSwitch() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isEnabled(null)).thenReturn(false);
+ when(mBroadcast.isProfileReady()).thenReturn(false);
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ verify(mPreference).setEnabled(true);
+
+ when(mBroadcast.isEnabled(null)).thenReturn(true);
+ when(mBroadcast.isProfileReady()).thenReturn(true);
+ mController.onServiceConnected();
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mBroadcast)
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcast.Callback.class));
+ verify(mBtProfileManager).removeServiceListener(mController);
+ verify(mPreference).setEnabled(false);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOn() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOff() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE);
+ }
+
+ @Test
+ public void getPreferenceKey_returnsCorrectKey() {
+ assertThat(mController.getPreferenceKey()).isEqualTo(PREF_KEY);
+ }
+
+ @Test
+ public void getSliceHighlightMenuRes_returnsZero() {
+ assertThat(mController.getSliceHighlightMenuRes()).isEqualTo(0);
+ }
+
+ @Test
+ public void displayPreference_broadcastOn_Disabled() {
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ verify(mPreference).setEnabled(false);
+ verify(mPreference)
+ .setSummary(
+ eq(mContext.getString(
+ R.string
+ .audio_sharing_stream_compatibility_disabled_description)));
+ }
+
+ @Test
+ public void displayPreference_broadcastOff_Enabled() {
+ when(mBroadcast.isEnabled(any())).thenReturn(false);
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ verify(mPreference).setEnabled(true);
+ verify(mPreference)
+ .setSummary(
+ eq(mContext.getString(
+ R.string.audio_sharing_stream_compatibility_description)));
+ }
+
+ @Test
+ public void isChecked_returnsTrue() {
+ when(mBroadcast.getImproveCompatibility()).thenReturn(true);
+ assertThat(mController.isChecked()).isTrue();
+ }
+
+ @Test
+ public void isChecked_returnsFalse() {
+ when(mBroadcast.getImproveCompatibility()).thenReturn(false);
+ assertThat(mController.isChecked()).isFalse();
+ mBroadcast = null;
+ assertThat(mController.isChecked()).isFalse();
+ }
+
+ @Test
+ public void setCheckedToNewValue_returnsTrue() {
+ when(mBroadcast.getImproveCompatibility()).thenReturn(true);
+ doNothing().when(mBroadcast).setImproveCompatibility(anyBoolean());
+ boolean setChecked = mController.setChecked(false);
+ verify(mBroadcast).setImproveCompatibility(false);
+ assertThat(setChecked).isTrue();
+ }
+
+ @Test
+ public void setCheckedToCurrentValue_returnsFalse() {
+ when(mBroadcast.getImproveCompatibility()).thenReturn(true);
+ boolean setChecked = mController.setChecked(true);
+ verify(mBroadcast, times(0)).setImproveCompatibility(anyBoolean());
+ assertThat(setChecked).isFalse();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragmentTest.java
new file mode 100644
index 0000000..c1afeaa
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragmentTest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.settings.SettingsEnums;
+
+import com.android.settings.R;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class AudioSharingDashboardFragmentTest {
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ private AudioSharingDashboardFragment mFragment;
+
+ @Before
+ public void setUp() {
+ mFragment = new AudioSharingDashboardFragment();
+ }
+
+ @Test
+ public void getPreferenceScreenResId_returnsCorrectXml() {
+ assertThat(mFragment.getPreferenceScreenResId())
+ .isEqualTo(R.xml.bluetooth_le_audio_sharing);
+ }
+
+ @Test
+ public void getLogTag_returnsCorrectTag() {
+ assertThat(mFragment.getLogTag()).isEqualTo("AudioSharingDashboardFrag");
+ }
+
+ @Test
+ public void getMetricsCategory_returnsCorrectCategory() {
+ assertThat(mFragment.getMetricsCategory()).isEqualTo(SettingsEnums.AUDIO_SHARING_SETTINGS);
+ }
+
+ @Test
+ public void getHelpResource_returnsCorrectResource() {
+ assertThat(mFragment.getHelpResource())
+ .isEqualTo(R.string.help_url_audio_sharing);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceItemTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceItemTest.java
new file mode 100644
index 0000000..1bae3d1
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceItemTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Parcel;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class AudioSharingDeviceItemTest {
+ private static final String TEST_NAME = "test";
+ private static final int TEST_GROUP_ID = 1;
+ private static final boolean TEST_IS_ACTIVE = true;
+
+ @Test
+ public void createItem_new() {
+ AudioSharingDeviceItem item =
+ new AudioSharingDeviceItem(TEST_NAME, TEST_GROUP_ID, TEST_IS_ACTIVE);
+ assertThat(item.getName()).isEqualTo(TEST_NAME);
+ assertThat(item.getGroupId()).isEqualTo(TEST_GROUP_ID);
+ assertThat(item.isActive()).isEqualTo(TEST_IS_ACTIVE);
+ }
+
+ @Test
+ public void createItem_withParcel() {
+ AudioSharingDeviceItem item =
+ new AudioSharingDeviceItem(TEST_NAME, TEST_GROUP_ID, TEST_IS_ACTIVE);
+ Parcel parcel = Parcel.obtain();
+ item.writeToParcel(parcel, 0);
+ parcel.setDataPosition(0);
+ AudioSharingDeviceItem newItem = new AudioSharingDeviceItem(parcel);
+ assertThat(newItem.getName()).isEqualTo(TEST_NAME);
+ assertThat(newItem.getGroupId()).isEqualTo(TEST_GROUP_ID);
+ assertThat(newItem.isActive()).isEqualTo(TEST_IS_ACTIVE);
+ }
+
+ @Test
+ public void describeContents_returnsZero() {
+ AudioSharingDeviceItem item =
+ new AudioSharingDeviceItem(TEST_NAME, TEST_GROUP_ID, TEST_IS_ACTIVE);
+ assertThat(item.describeContents()).isEqualTo(0);
+ }
+
+ @Test
+ public void creator_newArray() {
+ assertThat(AudioSharingDeviceItem.CREATOR.newArray(2)).hasLength(2);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDevicePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDevicePreferenceControllerTest.java
new file mode 100644
index 0000000..14bca08
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDevicePreferenceControllerTest.java
@@ -0,0 +1,487 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.android.settings.core.BasePreferenceController.AVAILABLE_UNSEARCHABLE;
+import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE;
+import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast.EXTRA_BLUETOOTH_DEVICE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Looper;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceManager;
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.SettingsActivity;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.dashboard.DashboardFragment;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+import com.android.settings.testutils.shadow.ShadowFragment;
+import com.android.settingslib.bluetooth.A2dpProfile;
+import com.android.settingslib.bluetooth.BluetoothCallback;
+import com.android.settingslib.bluetooth.BluetoothEventManager;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
+import com.android.settingslib.bluetooth.HeadsetProfile;
+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.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.flags.Flags;
+
+import com.google.common.collect.ImmutableList;
+
+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 org.robolectric.shadow.api.Shadow;
+
+import java.util.concurrent.Executor;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowBluetoothAdapter.class,
+ ShadowBluetoothUtils.class,
+ ShadowFragment.class,
+ })
+public class AudioSharingDevicePreferenceControllerTest {
+ private static final String KEY = "audio_sharing_device_list";
+ private static final String KEY_AUDIO_SHARING_SETTINGS =
+ "connected_device_audio_sharing_settings";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ @Mock private AudioSharingBluetoothDeviceUpdater mBluetoothDeviceUpdater;
+ @Mock private PreferenceManager mPreferenceManager;
+ @Mock private CachedBluetoothDevice mCachedDevice;
+ @Mock private BluetoothDevice mDevice;
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private BluetoothEventManager mEventManager;
+ @Mock private LocalBluetoothProfileManager mProfileManager;
+ @Mock private CachedBluetoothDeviceManager mDeviceManager;
+ @Mock private LocalBluetoothLeBroadcast mBroadcast;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Mock private VolumeControlProfile mVolumeControl;
+ @Mock private PreferenceScreen mScreen;
+ @Mock private AudioSharingDialogHandler mDialogHandler;
+ @Mock private DashboardFragment mFragment;
+ @Mock private FragmentActivity mActivity;
+ @Mock private LeAudioProfile mLeAudioProfile;
+ @Mock private A2dpProfile mA2dpProfile;
+ @Mock private HeadsetProfile mHeadsetProfile;
+
+ private Context mContext;
+ private AudioSharingDevicePreferenceController mController;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private Lifecycle mLifecycle;
+ private LifecycleOwner mLifecycleOwner;
+ private PreferenceCategory mPreferenceGroup;
+ private Preference mAudioSharingPreference;
+
+ @Before
+ public void setUp() {
+ mContext = ApplicationProvider.getApplicationContext();
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mLifecycleOwner = () -> mLifecycle;
+ mLifecycle = new Lifecycle(mLifecycleOwner);
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mLocalBtManager = Utils.getLocalBtManager(mContext);
+ when(mLocalBtManager.getEventManager()).thenReturn(mEventManager);
+ when(mLocalBtManager.getProfileManager()).thenReturn(mProfileManager);
+ when(mLocalBtManager.getCachedDeviceManager()).thenReturn(mDeviceManager);
+ when(mProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast);
+ when(mProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ when(mProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControl);
+ when(mBroadcast.isProfileReady()).thenReturn(true);
+ when(mAssistant.isProfileReady()).thenReturn(true);
+ when(mVolumeControl.isProfileReady()).thenReturn(true);
+ when(mDevice.getAnonymizedAddress()).thenReturn("");
+ doReturn(mDevice).when(mCachedDevice).getDevice();
+ when(mDeviceManager.findDevice(mDevice)).thenReturn(mCachedDevice);
+ when(mHeadsetProfile.getProfileId()).thenReturn(BluetoothProfile.HEADSET);
+ when(mA2dpProfile.getProfileId()).thenReturn(BluetoothProfile.A2DP);
+ when(mLeAudioProfile.getProfileId()).thenReturn(BluetoothProfile.LE_AUDIO);
+ when(mLeAudioProfile.isEnabled(mDevice)).thenReturn(true);
+ when(mScreen.getContext()).thenReturn(mContext);
+ mPreferenceGroup = spy(new PreferenceCategory(mContext));
+ doReturn(mPreferenceManager).when(mPreferenceGroup).getPreferenceManager();
+ mAudioSharingPreference = new Preference(mContext);
+ mPreferenceGroup.addPreference(mAudioSharingPreference);
+ when(mPreferenceGroup.findPreference(KEY_AUDIO_SHARING_SETTINGS))
+ .thenReturn(mAudioSharingPreference);
+ when(mScreen.findPreference(KEY)).thenReturn(mPreferenceGroup);
+ mController = new AudioSharingDevicePreferenceController(mContext);
+ mController.setBluetoothDeviceUpdater(mBluetoothDeviceUpdater);
+ mController.setDialogHandler(mDialogHandler);
+ doReturn(mActivity).when(mFragment).getActivity();
+ mController.setHostFragment(mFragment);
+ }
+
+ @Test
+ public void onStart_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mEventManager, times(0)).registerCallback(any(BluetoothCallback.class));
+ verify(mDialogHandler, times(0)).registerCallbacks(any(Executor.class));
+ verify(mAssistant, times(0))
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mBluetoothDeviceUpdater, times(0)).registerCallback();
+ verify(mBluetoothDeviceUpdater, times(0)).refreshPreference();
+ }
+
+ @Test
+ public void onStart_flagOn_registerCallbacks() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mEventManager).registerCallback(any(BluetoothCallback.class));
+ verify(mDialogHandler).registerCallbacks(any(Executor.class));
+ verify(mAssistant)
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mBluetoothDeviceUpdater).registerCallback();
+ verify(mBluetoothDeviceUpdater).refreshPreference();
+ }
+
+ @Test
+ public void onStop_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStop(mLifecycleOwner);
+ verify(mEventManager, times(0)).unregisterCallback(any(BluetoothCallback.class));
+ verify(mDialogHandler, times(0)).unregisterCallbacks();
+ verify(mAssistant, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mBluetoothDeviceUpdater, times(0)).unregisterCallback();
+ }
+
+ @Test
+ public void onStop_flagOn_unregisterCallbacks() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStop(mLifecycleOwner);
+ verify(mEventManager).unregisterCallback(any(BluetoothCallback.class));
+ verify(mDialogHandler).unregisterCallbacks();
+ verify(mAssistant)
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mBluetoothDeviceUpdater).unregisterCallback();
+ }
+
+ @Test
+ public void displayPreference_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.displayPreference(mScreen);
+ assertThat(mPreferenceGroup.isVisible()).isFalse();
+ assertThat(mAudioSharingPreference.isVisible()).isFalse();
+ verify(mBluetoothDeviceUpdater, times(0)).forceUpdate();
+ }
+
+ @Test
+ public void displayPreference_flagOn_updateDeviceList() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.displayPreference(mScreen);
+ assertThat(mPreferenceGroup.isVisible()).isFalse();
+ assertThat(mAudioSharingPreference.isVisible()).isFalse();
+ verify(mBluetoothDeviceUpdater).setPrefContext(mContext);
+ verify(mBluetoothDeviceUpdater).forceUpdate();
+ }
+
+ @Test
+ public void getPreferenceKey_returnsCorrectKey() {
+ assertThat(mController.getPreferenceKey()).isEqualTo(KEY);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOff_returnUnSupported() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOn_returnSupported() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE_UNSEARCHABLE);
+ }
+
+ @Test
+ public void onDeviceAdded_firstDevice_updateVisibility() {
+ mController.displayPreference(mScreen);
+ Preference preference = new Preference(mContext);
+ mController.onDeviceAdded(preference);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ assertThat(mPreferenceGroup.isVisible()).isTrue();
+ assertThat(mAudioSharingPreference.isVisible()).isTrue();
+ assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(2);
+ }
+
+ @Test
+ public void onDeviceRemoved_lastDevice_updateVisibility() {
+ Preference preference = new Preference(mContext);
+ mPreferenceGroup.addPreference(preference);
+ mController.displayPreference(mScreen);
+ mController.onDeviceRemoved(preference);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ assertThat(mPreferenceGroup.isVisible()).isFalse();
+ assertThat(mAudioSharingPreference.isVisible()).isFalse();
+ assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(1);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_notMediaDevice_doNothing() {
+ doReturn(ImmutableList.of()).when(mCachedDevice).getConnectableProfiles();
+ mController.onProfileConnectionStateChanged(
+ mCachedDevice, BluetoothAdapter.STATE_CONNECTED, BluetoothProfile.HID_DEVICE);
+ verifyNoInteractions(mDialogHandler);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceDisconnected_closeOpeningDialogsForIt() {
+ // Test when LEA device LE_AUDIO_BROADCAST_ASSISTANT disconnected.
+ when(mDevice.isConnected()).thenReturn(true);
+ doReturn(ImmutableList.of(mLeAudioProfile)).when(mCachedDevice).getConnectableProfiles();
+ doReturn(ImmutableList.of(mLeAudioProfile)).when(mCachedDevice).getProfiles();
+ mController.onProfileConnectionStateChanged(
+ mCachedDevice,
+ BluetoothAdapter.STATE_DISCONNECTED,
+ BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT);
+ verify(mDialogHandler).closeOpeningDialogsForLeaDevice(mCachedDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_assistantProfileConnecting_doNothing() {
+ // Test when LEA device LE_AUDIO_BROADCAST_ASSISTANT connecting
+ doReturn(ImmutableList.of(mLeAudioProfile)).when(mCachedDevice).getConnectableProfiles();
+ doReturn(ImmutableList.of(mLeAudioProfile)).when(mCachedDevice).getProfiles();
+ mController.onProfileConnectionStateChanged(
+ mCachedDevice,
+ BluetoothAdapter.STATE_CONNECTING,
+ BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT);
+ verifyNoInteractions(mDialogHandler);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_otherProfileConnected_doNothing() {
+ // Test when LEA device other profile connected
+ when(mDevice.isConnected()).thenReturn(true);
+ doReturn(ImmutableList.of(mLeAudioProfile)).when(mCachedDevice).getConnectableProfiles();
+ doReturn(ImmutableList.of(mLeAudioProfile)).when(mCachedDevice).getProfiles();
+ mController.onProfileConnectionStateChanged(
+ mCachedDevice, BluetoothAdapter.STATE_CONNECTED, BluetoothProfile.A2DP);
+ verifyNoInteractions(mDialogHandler);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_otherProfileConnecting_doNothing() {
+ // Test when LEA device other profile connecting
+ when(mDevice.isConnected()).thenReturn(true);
+ doReturn(ImmutableList.of(mLeAudioProfile)).when(mCachedDevice).getConnectableProfiles();
+ doReturn(ImmutableList.of(mLeAudioProfile)).when(mCachedDevice).getProfiles();
+ mController.onProfileConnectionStateChanged(
+ mCachedDevice, BluetoothAdapter.STATE_CONNECTING, BluetoothProfile.A2DP);
+ verifyNoInteractions(mDialogHandler);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_assistantProfileConnected_handle() {
+ // Test when LEA device LE_AUDIO_BROADCAST_ASSISTANT connected
+ when(mDevice.isConnected()).thenReturn(true);
+ doReturn(ImmutableList.of(mLeAudioProfile)).when(mCachedDevice).getConnectableProfiles();
+ doReturn(ImmutableList.of(mLeAudioProfile)).when(mCachedDevice).getProfiles();
+ mController.onProfileConnectionStateChanged(
+ mCachedDevice,
+ BluetoothAdapter.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT);
+ verify(mDialogHandler).handleDeviceConnected(mCachedDevice, false);
+ }
+
+ @Test
+ public void
+ onProfileConnectionStateChanged_nonLeaDeviceDisconnected_closeOpeningDialogsForIt() {
+ // Test when non-LEA device totally disconnected
+ when(mLeAudioProfile.isEnabled(mDevice)).thenReturn(false);
+ doReturn(ImmutableList.of(mA2dpProfile)).when(mCachedDevice).getConnectableProfiles();
+ doReturn(ImmutableList.of(mLeAudioProfile, mA2dpProfile)).when(mCachedDevice).getProfiles();
+ when(mCachedDevice.isConnected()).thenReturn(false);
+ mController.onProfileConnectionStateChanged(
+ mCachedDevice, BluetoothAdapter.STATE_DISCONNECTED, BluetoothProfile.A2DP);
+ verify(mDialogHandler).closeOpeningDialogsForNonLeaDevice(mCachedDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_nonLeaNotFirstProfileConnected_doNothing() {
+ // Test when non-LEA device LE_AUDIO_BROADCAST_ASSISTANT connecting
+ when(mDevice.isConnected()).thenReturn(true);
+ when(mHeadsetProfile.getConnectionStatus(mDevice))
+ .thenReturn(BluetoothAdapter.STATE_CONNECTED);
+ doReturn(ImmutableList.of(mA2dpProfile, mHeadsetProfile))
+ .when(mCachedDevice)
+ .getConnectableProfiles();
+ doReturn(ImmutableList.of(mA2dpProfile, mHeadsetProfile)).when(mCachedDevice).getProfiles();
+ mController.onProfileConnectionStateChanged(
+ mCachedDevice, BluetoothAdapter.STATE_CONNECTED, BluetoothProfile.A2DP);
+ verifyNoInteractions(mDialogHandler);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_nonLeaFirstProfileConnected_handle() {
+ // Test when non-LEA device LE_AUDIO_BROADCAST_ASSISTANT connecting
+ when(mDevice.isConnected()).thenReturn(true);
+ when(mHeadsetProfile.getConnectionStatus(mDevice))
+ .thenReturn(BluetoothAdapter.STATE_DISCONNECTED);
+ doReturn(ImmutableList.of(mA2dpProfile, mHeadsetProfile))
+ .when(mCachedDevice)
+ .getConnectableProfiles();
+ doReturn(ImmutableList.of(mA2dpProfile, mHeadsetProfile)).when(mCachedDevice).getProfiles();
+ mController.onProfileConnectionStateChanged(
+ mCachedDevice, BluetoothAdapter.STATE_CONNECTED, BluetoothProfile.A2DP);
+ verify(mDialogHandler).handleDeviceConnected(mCachedDevice, false);
+ }
+
+ @Test
+ public void handleDeviceClickFromIntent_noDevice_doNothing() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ Intent intent = new Intent();
+ intent.putExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, new Bundle());
+ doReturn(intent).when(mActivity).getIntent();
+ mController.displayPreference(mScreen);
+
+ verify(mDeviceManager, times(0)).findDevice(any(BluetoothDevice.class));
+ verify(mDialogHandler, times(0))
+ .handleDeviceConnected(any(CachedBluetoothDevice.class), anyBoolean());
+ }
+
+ @Test
+ public void handleDeviceClickFromIntent_profileNotReady_doNothing() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isProfileReady()).thenReturn(false);
+ Bundle arg = new Bundle();
+ arg.putParcelable(EXTRA_BLUETOOTH_DEVICE, mDevice);
+ Intent intent = new Intent();
+ intent.putExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, arg);
+ doReturn(intent).when(mActivity).getIntent();
+ when(mDevice.isConnected()).thenReturn(false);
+ mController.displayPreference(mScreen);
+
+ verify(mDeviceManager, times(0)).findDevice(any(BluetoothDevice.class));
+ verify(mDialogHandler, times(0))
+ .handleDeviceConnected(any(CachedBluetoothDevice.class), anyBoolean());
+ }
+
+ @Test
+ public void handleDeviceClickFromIntent_intentHandled_handle() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ Bundle arg = new Bundle();
+ arg.putParcelable(EXTRA_BLUETOOTH_DEVICE, mDevice);
+ Intent intent = new Intent();
+ intent.putExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, arg);
+ doReturn(intent).when(mActivity).getIntent();
+ when(mDevice.isConnected()).thenReturn(true);
+ when(mCachedDevice.isConnected()).thenReturn(true);
+ mController.setIntentHandled(true);
+ mController.displayPreference(mScreen);
+
+ verify(mDeviceManager, times(0)).findDevice(any(BluetoothDevice.class));
+ verify(mDialogHandler, times(0))
+ .handleDeviceConnected(any(CachedBluetoothDevice.class), anyBoolean());
+ }
+
+ @Test
+ public void handleDeviceClickFromIntent_disconnectedDevice_connect() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ Bundle arg = new Bundle();
+ arg.putParcelable(EXTRA_BLUETOOTH_DEVICE, mDevice);
+ Intent intent = new Intent();
+ intent.putExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, arg);
+ doReturn(intent).when(mActivity).getIntent();
+ when(mDevice.isConnected()).thenReturn(false);
+ mController.displayPreference(mScreen);
+
+ verify(mCachedDevice).connect();
+ }
+
+ @Test
+ public void handleDeviceClickFromIntent_connectedDevice_handle() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ Bundle arg = new Bundle();
+ arg.putParcelable(EXTRA_BLUETOOTH_DEVICE, mDevice);
+ Intent intent = new Intent();
+ intent.putExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, arg);
+ doReturn(intent).when(mActivity).getIntent();
+ when(mDevice.isConnected()).thenReturn(true);
+ when(mCachedDevice.isConnected()).thenReturn(true);
+ mController.displayPreference(mScreen);
+
+ verify(mDialogHandler).handleDeviceConnected(mCachedDevice, true);
+ }
+
+ @Test
+ public void handleDeviceClickFromIntent_onServiceConnected_handle() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ Bundle arg = new Bundle();
+ arg.putParcelable(EXTRA_BLUETOOTH_DEVICE, mDevice);
+ Intent intent = new Intent();
+ intent.putExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS, arg);
+ doReturn(intent).when(mActivity).getIntent();
+ when(mDevice.isConnected()).thenReturn(true);
+ when(mCachedDevice.isConnected()).thenReturn(true);
+ mController.onServiceConnected();
+
+ verify(mDialogHandler).handleDeviceConnected(mCachedDevice, true);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeControlUpdaterTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeControlUpdaterTest.java
new file mode 100644
index 0000000..a8563d1
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeControlUpdaterTest.java
@@ -0,0 +1,299 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothProfile;
+import android.content.Context;
+import android.media.AudioManager;
+import android.os.Looper;
+import android.provider.Settings;
+import android.widget.SeekBar;
+
+import androidx.preference.Preference;
+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.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+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.Collection;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {ShadowBluetoothUtils.class})
+public class AudioSharingDeviceVolumeControlUpdaterTest {
+ private static final String TEST_DEVICE_NAME = "test";
+ private static final String TAG = "AudioSharingDeviceVolumeControlUpdater";
+ private static final String PREF_KEY = "audio_sharing_volume_control";
+ private static final String TEST_SETTINGS_KEY =
+ "bluetooth_le_broadcast_fallback_active_group_id";
+ private static final int TEST_DEVICE_GROUP_ID = 1;
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+
+ @Mock private DevicePreferenceCallback mDevicePreferenceCallback;
+ @Mock private CachedBluetoothDevice mCachedBluetoothDevice;
+ @Mock private BluetoothDevice mBluetoothDevice;
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private CachedBluetoothDeviceManager mCachedDeviceManager;
+ @Mock private LocalBluetoothProfileManager mLocalBtProfileManager;
+ @Mock private LocalBluetoothLeBroadcast mBroadcast;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Mock private VolumeControlProfile mVolumeControl;
+ @Mock private BluetoothLeBroadcastReceiveState mState;
+ @Mock private AudioManager mAudioManager;
+
+ private Context mContext;
+ private AudioSharingDeviceVolumeControlUpdater mDeviceUpdater;
+ private Collection<CachedBluetoothDevice> mCachedDevices;
+
+ @Before
+ public void setUp() {
+ mContext = spy(ApplicationProvider.getApplicationContext());
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mLocalBtManager = Utils.getLocalBtManager(mContext);
+ when(mLocalBtManager.getCachedDeviceManager()).thenReturn(mCachedDeviceManager);
+ when(mLocalBtManager.getProfileManager()).thenReturn(mLocalBtProfileManager);
+ when(mLocalBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast);
+ when(mLocalBtProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ when(mLocalBtProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControl);
+ List<Long> bisSyncState = new ArrayList<>();
+ bisSyncState.add(1L);
+ when(mState.getBisSyncState()).thenReturn(bisSyncState);
+ doReturn(TEST_DEVICE_NAME).when(mCachedBluetoothDevice).getName();
+ doReturn(mBluetoothDevice).when(mCachedBluetoothDevice).getDevice();
+ doReturn(ImmutableSet.of()).when(mCachedBluetoothDevice).getMemberDevice();
+ doReturn(TEST_DEVICE_GROUP_ID).when(mCachedBluetoothDevice).getGroupId();
+ mCachedDevices = new ArrayList<>();
+ mCachedDevices.add(mCachedBluetoothDevice);
+ when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(mCachedDevices);
+ doNothing().when(mDevicePreferenceCallback).onDeviceAdded(any(Preference.class));
+ doNothing().when(mDevicePreferenceCallback).onDeviceRemoved(any(Preference.class));
+ when(mContext.getSystemService(AudioManager.class)).thenReturn(mAudioManager);
+ mDeviceUpdater =
+ spy(
+ new AudioSharingDeviceVolumeControlUpdater(
+ mContext, mDevicePreferenceCallback, /* metricsCategory= */ 0));
+ mDeviceUpdater.setPrefContext(mContext);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceConnected_noSharing_removesPref() {
+ setupPreferenceMapWithDevice();
+
+ when(mBroadcast.isEnabled(null)).thenReturn(false);
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mDevicePreferenceCallback).onDeviceRemoved(captor.capture());
+ assertThat(captor.getValue() instanceof AudioSharingDeviceVolumePreference).isTrue();
+ assertThat(((AudioSharingDeviceVolumePreference) captor.getValue()).getCachedDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceConnected_noSource_removesPref() {
+ setupPreferenceMapWithDevice();
+
+ when(mAssistant.getAllSources(mBluetoothDevice)).thenReturn(ImmutableList.of());
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mDevicePreferenceCallback).onDeviceRemoved(captor.capture());
+ assertThat(captor.getValue() instanceof AudioSharingDeviceVolumePreference).isTrue();
+ assertThat(((AudioSharingDeviceVolumePreference) captor.getValue()).getCachedDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_deviceIsNotInList_removesPref() {
+ setupPreferenceMapWithDevice();
+
+ mCachedDevices.clear();
+ when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn(mCachedDevices);
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mDevicePreferenceCallback).onDeviceRemoved(captor.capture());
+ assertThat(captor.getValue() instanceof AudioSharingDeviceVolumePreference).isTrue();
+ assertThat(((AudioSharingDeviceVolumePreference) captor.getValue()).getCachedDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceDisconnected_removesPref() {
+ setupPreferenceMapWithDevice();
+
+ when(mDeviceUpdater.isDeviceConnected(any(CachedBluetoothDevice.class))).thenReturn(false);
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_DISCONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mDevicePreferenceCallback).onDeviceRemoved(captor.capture());
+ assertThat(captor.getValue() instanceof AudioSharingDeviceVolumePreference).isTrue();
+ assertThat(((AudioSharingDeviceVolumePreference) captor.getValue()).getCachedDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceDisconnecting_removesPref() {
+ setupPreferenceMapWithDevice();
+
+ when(mCachedBluetoothDevice.isConnectedLeAudioDevice()).thenReturn(false);
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mDevicePreferenceCallback).onDeviceRemoved(captor.capture());
+ assertThat(captor.getValue() instanceof AudioSharingDeviceVolumePreference).isTrue();
+ assertThat(((AudioSharingDeviceVolumePreference) captor.getValue()).getCachedDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_leaDeviceConnected_hasSource_addsPreference() {
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+ setupPreferenceMapWithDevice();
+
+ verify(mDevicePreferenceCallback).onDeviceAdded(captor.capture());
+ assertThat(captor.getValue() instanceof AudioSharingDeviceVolumePreference).isTrue();
+ assertThat(((AudioSharingDeviceVolumePreference) captor.getValue()).getCachedDevice())
+ .isEqualTo(mCachedBluetoothDevice);
+ }
+
+ @Test
+ public void addPreference_notFallbackDevice_setDeviceVolume() {
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+ setupPreferenceMapWithDevice();
+
+ verify(mDevicePreferenceCallback).onDeviceAdded(captor.capture());
+ assertThat(captor.getValue() instanceof AudioSharingDeviceVolumePreference).isTrue();
+ AudioSharingDeviceVolumePreference preference =
+ (AudioSharingDeviceVolumePreference) captor.getValue();
+
+ SeekBar seekBar = mock(SeekBar.class);
+ when(seekBar.getProgress()).thenReturn(255);
+ preference.onStopTrackingTouch(seekBar);
+
+ verify(mVolumeControl).setDeviceVolume(mBluetoothDevice, 255, true);
+ verifyNoInteractions(mAudioManager);
+ }
+
+ @Test
+ public void addPreference_fallbackDevice_setStreamVolume() {
+ ArgumentCaptor<Preference> captor = ArgumentCaptor.forClass(Preference.class);
+ setupPreferenceMapWithDevice();
+
+ verify(mDevicePreferenceCallback).onDeviceAdded(captor.capture());
+ assertThat(captor.getValue() instanceof AudioSharingDeviceVolumePreference).isTrue();
+ AudioSharingDeviceVolumePreference preference =
+ (AudioSharingDeviceVolumePreference) captor.getValue();
+
+ Settings.Secure.putInt(
+ mContext.getContentResolver(), TEST_SETTINGS_KEY, TEST_DEVICE_GROUP_ID);
+ when(mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)).thenReturn(10);
+ when(mAudioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)).thenReturn(0);
+ SeekBar seekBar = mock(SeekBar.class);
+ when(seekBar.getProgress()).thenReturn(255);
+ preference.onStopTrackingTouch(seekBar);
+
+ verifyNoInteractions(mVolumeControl);
+ verify(mAudioManager).setStreamVolume(AudioManager.STREAM_MUSIC, 10, 0);
+ }
+
+ @Test
+ public void getLogTag_returnsCorrectTag() {
+ assertThat(mDeviceUpdater.getLogTag()).isEqualTo(TAG);
+ }
+
+ @Test
+ public void getPreferenceKey_returnsCorrectKey() {
+ assertThat(mDeviceUpdater.getPreferenceKey()).isEqualTo(PREF_KEY);
+ }
+
+ private void setupPreferenceMapWithDevice() {
+ // Add device to preferenceMap
+ when(mBroadcast.isEnabled(null)).thenReturn(true);
+ when(mAssistant.getAllSources(mBluetoothDevice)).thenReturn(ImmutableList.of(mState));
+ when(mDeviceUpdater.isDeviceConnected(any(CachedBluetoothDevice.class))).thenReturn(true);
+ when(mCachedBluetoothDevice.isConnectedLeAudioDevice()).thenReturn(true);
+ mDeviceUpdater.onProfileConnectionStateChanged(
+ mCachedBluetoothDevice,
+ BluetoothProfile.STATE_CONNECTED,
+ BluetoothProfile.LE_AUDIO);
+ shadowOf(Looper.getMainLooper()).idle();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeGroupControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeGroupControllerTest.java
new file mode 100644
index 0000000..7c8709c
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumeGroupControllerTest.java
@@ -0,0 +1,414 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothStatusCodes;
+import android.bluetooth.BluetoothVolumeControl;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.media.AudioManager;
+import android.os.Looper;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.provider.Settings;
+
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceManager;
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.connecteddevice.DevicePreferenceCallback;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+import com.android.settings.testutils.shadow.ShadowThreadUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.flags.Flags;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+import java.util.concurrent.Executor;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowBluetoothAdapter.class,
+ ShadowBluetoothUtils.class,
+ ShadowThreadUtils.class,
+ })
+public class AudioSharingDeviceVolumeGroupControllerTest {
+ private static final String TEST_DEVICE_NAME1 = "test1";
+ private static final String TEST_DEVICE_NAME2 = "test2";
+ private static final int TEST_DEVICE_GROUP_ID1 = 1;
+ private static final int TEST_DEVICE_GROUP_ID2 = 2;
+ private static final int TEST_VOLUME_VALUE = 10;
+ private static final int TEST_INVALID_VOLUME_VALUE = -1;
+ private static final int TEST_MAX_VOLUME_VALUE = 100;
+ private static final int TEST_MIN_VOLUME_VALUE = 0;
+ private static final String PREF_KEY = "audio_sharing_device_volume_group";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ @Mock private DevicePreferenceCallback mDevicePreferenceCallback;
+ @Mock private CachedBluetoothDevice mCachedDevice1;
+ @Mock private CachedBluetoothDevice mCachedDevice2;
+ @Mock private BluetoothDevice mDevice1;
+ @Mock private BluetoothDevice mDevice2;
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private CachedBluetoothDeviceManager mCachedDeviceManager;
+ @Mock private LocalBluetoothProfileManager mProfileManager;
+ @Mock private LocalBluetoothLeBroadcast mBroadcast;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Mock private AudioSharingDeviceVolumeControlUpdater mDeviceUpdater;
+ @Mock private VolumeControlProfile mVolumeControl;
+ @Mock private PreferenceScreen mScreen;
+ @Mock private AudioSharingDeviceVolumePreference mPreference1;
+ @Mock private AudioSharingDeviceVolumePreference mPreference2;
+ @Mock private AudioManager mAudioManager;
+ @Mock private PreferenceManager mPreferenceManager;
+ @Mock private ContentResolver mContentResolver;
+ @Spy private ContentObserver mContentObserver;
+
+ private Context mContext;
+ private AudioSharingDeviceVolumeGroupController mController;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private Lifecycle mLifecycle;
+ private LifecycleOwner mLifecycleOwner;
+ private PreferenceCategory mPreferenceGroup;
+
+ @Before
+ public void setUp() {
+ mContext = spy(ApplicationProvider.getApplicationContext());
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mLifecycleOwner = () -> mLifecycle;
+ mLifecycle = new Lifecycle(mLifecycleOwner);
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mLocalBtManager = Utils.getLocalBtManager(mContext);
+ when(mLocalBtManager.getCachedDeviceManager()).thenReturn(mCachedDeviceManager);
+ when(mLocalBtManager.getProfileManager()).thenReturn(mProfileManager);
+ when(mProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast);
+ when(mProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ when(mProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControl);
+ when(mBroadcast.isProfileReady()).thenReturn(true);
+ when(mAssistant.isProfileReady()).thenReturn(true);
+ when(mVolumeControl.isProfileReady()).thenReturn(true);
+ when(mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC))
+ .thenReturn(TEST_MAX_VOLUME_VALUE);
+ when(mAudioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC))
+ .thenReturn(TEST_MIN_VOLUME_VALUE);
+ when(mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC))
+ .thenReturn(TEST_VOLUME_VALUE);
+ when(mContext.getSystemService(AudioManager.class)).thenReturn(mAudioManager);
+ when(mContext.getContentResolver()).thenReturn(mContentResolver);
+ doReturn(TEST_DEVICE_NAME1).when(mCachedDevice1).getName();
+ doReturn(TEST_DEVICE_GROUP_ID1).when(mCachedDevice1).getGroupId();
+ doReturn(mDevice1).when(mCachedDevice1).getDevice();
+ doReturn(ImmutableSet.of()).when(mCachedDevice1).getMemberDevice();
+ when(mPreference1.getCachedDevice()).thenReturn(mCachedDevice1);
+ doReturn(TEST_DEVICE_NAME2).when(mCachedDevice2).getName();
+ doReturn(TEST_DEVICE_GROUP_ID2).when(mCachedDevice2).getGroupId();
+ doReturn(mDevice2).when(mCachedDevice2).getDevice();
+ doReturn(ImmutableSet.of()).when(mCachedDevice2).getMemberDevice();
+ when(mPreference2.getCachedDevice()).thenReturn(mCachedDevice2);
+ doNothing().when(mDevicePreferenceCallback).onDeviceAdded(any(Preference.class));
+ doNothing().when(mDevicePreferenceCallback).onDeviceRemoved(any(Preference.class));
+ when(mScreen.getContext()).thenReturn(mContext);
+ mPreferenceGroup = spy(new PreferenceCategory(mContext));
+ doReturn(mPreferenceManager).when(mPreferenceGroup).getPreferenceManager();
+ when(mScreen.findPreference(PREF_KEY)).thenReturn(mPreferenceGroup);
+ mController = new AudioSharingDeviceVolumeGroupController(mContext);
+ mController.setDeviceUpdater(mDeviceUpdater);
+ mContentObserver = mController.getSettingsObserver();
+ }
+
+ @Test
+ public void onStart_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mAssistant, times(0))
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mDeviceUpdater, times(0)).registerCallback();
+ verify(mVolumeControl, times(0))
+ .registerCallback(any(Executor.class), any(BluetoothVolumeControl.Callback.class));
+ verify(mContentResolver, times(0))
+ .registerContentObserver(
+ Settings.Secure.getUriFor(SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID),
+ false,
+ mContentObserver);
+ }
+
+ @Test
+ public void onStart_flagOn_registerCallbacks() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mAssistant)
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mDeviceUpdater).registerCallback();
+ verify(mVolumeControl)
+ .registerCallback(any(Executor.class), any(BluetoothVolumeControl.Callback.class));
+ verify(mContentResolver)
+ .registerContentObserver(
+ Settings.Secure.getUriFor(SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID),
+ false,
+ mContentObserver);
+ }
+
+ @Test
+ public void onStop_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStop(mLifecycleOwner);
+ verify(mAssistant, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mDeviceUpdater, times(0)).unregisterCallback();
+ verify(mVolumeControl, times(0))
+ .unregisterCallback(any(BluetoothVolumeControl.Callback.class));
+ verify(mContentResolver, times(0)).unregisterContentObserver(mContentObserver);
+ }
+
+ @Test
+ public void onStop_flagOn_callbacksNotRegistered_doNothing() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(false);
+ mController.onStop(mLifecycleOwner);
+ verify(mAssistant, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mDeviceUpdater, times(0)).unregisterCallback();
+ verify(mVolumeControl, times(0))
+ .unregisterCallback(any(BluetoothVolumeControl.Callback.class));
+ verify(mContentResolver, times(0)).unregisterContentObserver(mContentObserver);
+ }
+
+ @Test
+ public void onStop_flagOn_callbacksRegistered_unregisterCallbacks() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(true);
+ mController.onStop(mLifecycleOwner);
+ verify(mAssistant)
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mDeviceUpdater).unregisterCallback();
+ verify(mVolumeControl).unregisterCallback(any(BluetoothVolumeControl.Callback.class));
+ verify(mContentResolver).unregisterContentObserver(mContentObserver);
+ }
+
+ @Test
+ public void displayPreference_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.displayPreference(mScreen);
+ assertThat(mPreferenceGroup.isVisible()).isFalse();
+ verify(mDeviceUpdater, times(0)).forceUpdate();
+ }
+
+ @Test
+ public void displayPreference_flagOn_updateDeviceList() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.displayPreference(mScreen);
+ assertThat(mPreferenceGroup.isVisible()).isFalse();
+ verify(mDeviceUpdater).forceUpdate();
+ }
+
+ @Test
+ public void getPreferenceKey_returnsCorrectKey() {
+ assertThat(mController.getPreferenceKey()).isEqualTo(PREF_KEY);
+ }
+
+ @Test
+ public void onDeviceAdded_firstDevice_updateVisibility() {
+ when(mPreference1.getProgress()).thenReturn(TEST_VOLUME_VALUE);
+ mController.setPreferenceGroup(mPreferenceGroup);
+ mController.onDeviceAdded(mPreference1);
+ verify(mPreferenceGroup).setVisible(true);
+ assertThat(mPreferenceGroup.isVisible()).isTrue();
+ }
+
+ @Test
+ public void onDeviceAdded_rankFallbackDeviceOnTop() {
+ Settings.Secure.putInt(
+ mContentResolver, SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID, TEST_DEVICE_GROUP_ID2);
+ when(mPreference1.getProgress()).thenReturn(TEST_VOLUME_VALUE);
+ when(mPreference2.getProgress()).thenReturn(TEST_VOLUME_VALUE);
+ mController.setPreferenceGroup(mPreferenceGroup);
+ mController.onDeviceAdded(mPreference1);
+ mController.onDeviceAdded(mPreference2);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mPreference1).setOrder(1);
+ verify(mPreference2).setOrder(0);
+ }
+
+ @Test
+ public void onDeviceAdded_setVolumeFromVolumeControlService() {
+ when(mPreference1.getProgress()).thenReturn(TEST_INVALID_VOLUME_VALUE);
+ mController.setVolumeMap(ImmutableMap.of(TEST_DEVICE_GROUP_ID1, TEST_VOLUME_VALUE));
+ mController.setPreferenceGroup(mPreferenceGroup);
+ mController.onDeviceAdded(mPreference1);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mPreference1).setProgress(eq(TEST_VOLUME_VALUE));
+ }
+
+ @Test
+ public void onDeviceAdded_setVolumeFromAudioManager() {
+ when(mPreference1.getProgress()).thenReturn(TEST_INVALID_VOLUME_VALUE);
+ mController.setPreferenceGroup(mPreferenceGroup);
+ mController.onDeviceAdded(mPreference1);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mPreference1).setProgress(eq(26));
+ }
+
+ @Test
+ public void onDeviceRemoved_notLastDevice_isVisible() {
+ mPreferenceGroup.addPreference(mPreference2);
+ mPreferenceGroup.addPreference(mPreference1);
+ mController.setPreferenceGroup(mPreferenceGroup);
+ mController.onDeviceRemoved(mPreference1);
+ verify(mPreferenceGroup, times(0)).setVisible(false);
+ assertThat(mPreferenceGroup.isVisible()).isTrue();
+ }
+
+ @Test
+ public void onDeviceRemoved_lastDevice_updateVisibility() {
+ mPreferenceGroup.addPreference(mPreference1);
+ mController.setPreferenceGroup(mPreferenceGroup);
+ mController.onDeviceRemoved(mPreference1);
+ verify(mPreferenceGroup).setVisible(false);
+ assertThat(mPreferenceGroup.isVisible()).isFalse();
+ }
+
+ @Test
+ public void updateVisibility_emptyPreferenceGroup_doNothing() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(true);
+ mController.updateVisibility();
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mPreferenceGroup, times(0)).setVisible(anyBoolean());
+ }
+
+ @Test
+ public void updateVisibility_flagOff_setVisibleToFalse() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(true);
+ mPreferenceGroup.addPreference(mPreference1);
+ when(mBroadcast.isEnabled(null)).thenReturn(true);
+ mController.setPreferenceGroup(mPreferenceGroup);
+ mController.updateVisibility();
+ shadowOf(Looper.getMainLooper()).idle();
+
+ assertThat(mPreferenceGroup.getPreferenceCount() > 0).isTrue();
+ verify(mPreferenceGroup).setVisible(false);
+ assertThat(mPreferenceGroup.isVisible()).isFalse();
+ }
+
+ @Test
+ public void updateVisibility_notEmptyPreferenceGroup_noSharing_setVisibleToFalse() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(true);
+ mPreferenceGroup.addPreference(mPreference1);
+ when(mBroadcast.isEnabled(null)).thenReturn(false);
+ mController.setPreferenceGroup(mPreferenceGroup);
+ mController.updateVisibility();
+ shadowOf(Looper.getMainLooper()).idle();
+
+ assertThat(mPreferenceGroup.getPreferenceCount() > 0).isTrue();
+ verify(mPreferenceGroup).setVisible(false);
+ assertThat(mPreferenceGroup.isVisible()).isFalse();
+ }
+
+ @Test
+ public void updateVisibility_notEmptyPreferenceGroup_isSharing_setVisibleToTrue() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(true);
+ mPreferenceGroup.addPreference(mPreference1);
+ when(mBroadcast.isEnabled(null)).thenReturn(true);
+ mController.setPreferenceGroup(mPreferenceGroup);
+ mController.updateVisibility();
+ shadowOf(Looper.getMainLooper()).idle();
+
+ assertThat(mPreferenceGroup.getPreferenceCount() > 0).isTrue();
+ verify(mPreferenceGroup).setVisible(true);
+ assertThat(mPreferenceGroup.isVisible()).isTrue();
+ }
+
+ @Test
+ public void settingsObserverOnChange_updatePreferenceOrder() {
+ Settings.Secure.putInt(
+ mContentResolver, SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID, TEST_DEVICE_GROUP_ID2);
+ when(mPreference1.getProgress()).thenReturn(TEST_VOLUME_VALUE);
+ when(mPreference2.getProgress()).thenReturn(TEST_VOLUME_VALUE);
+ mController.setPreferenceGroup(mPreferenceGroup);
+ mController.onDeviceAdded(mPreference1);
+ mController.onDeviceAdded(mPreference2);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ Settings.Secure.putInt(
+ mContentResolver, SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID, TEST_DEVICE_GROUP_ID1);
+ mContentObserver.onChange(true);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mPreference1).setOrder(0);
+ verify(mPreference2).setOrder(1);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumePreferenceTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumePreferenceTest.java
new file mode 100644
index 0000000..8ceb0eb
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceVolumePreferenceTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+
+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;
+
+@RunWith(RobolectricTestRunner.class)
+public class AudioSharingDeviceVolumePreferenceTest {
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+
+ @Mock private CachedBluetoothDevice mCachedDevice;
+ private Context mContext;
+ private AudioSharingDeviceVolumePreference mPreference;
+
+ @Before
+ public void setup() {
+ mContext = ApplicationProvider.getApplicationContext();
+ mPreference = new AudioSharingDeviceVolumePreference(mContext, mCachedDevice);
+ }
+
+ @Test
+ public void getCachedDevice_returnsDevice() {
+ assertThat(mPreference.getCachedDevice()).isEqualTo(mCachedDevice);
+ }
+
+ @Test
+ public void initialize_setupMaxMin() {
+ mPreference.initialize();
+ assertThat(mPreference.getMax()).isEqualTo(AudioSharingDeviceVolumePreference.MAX_VOLUME);
+ assertThat(mPreference.getMin()).isEqualTo(AudioSharingDeviceVolumePreference.MIN_VOLUME);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragmentTest.java
new file mode 100644
index 0000000..4336e77
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragmentTest.java
@@ -0,0 +1,233 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothStatusCodes;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.view.View;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.settings.R;
+import com.android.settings.testutils.shadow.ShadowAlertDialogCompat;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settingslib.flags.Flags;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.androidx.fragment.FragmentController;
+
+import java.util.ArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowAlertDialogCompat.class,
+ ShadowBluetoothAdapter.class,
+ })
+public class AudioSharingDialogFragmentTest {
+
+ @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ private static final String TEST_DEVICE_NAME1 = "test1";
+ private static final String TEST_DEVICE_NAME2 = "test2";
+ private static final String TEST_DEVICE_NAME3 = "test3";
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM1 =
+ new AudioSharingDeviceItem(TEST_DEVICE_NAME1, /* groupId= */ 1, /* isActive= */ false);
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM2 =
+ new AudioSharingDeviceItem(TEST_DEVICE_NAME2, /* groupId= */ 2, /* isActive= */ false);
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM3 =
+ new AudioSharingDeviceItem(TEST_DEVICE_NAME3, /* groupId= */ 3, /* isActive= */ false);
+
+ private Fragment mParent;
+ private AudioSharingDialogFragment mFragment;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+
+ @Before
+ public void setUp() {
+ ShadowAlertDialogCompat.reset();
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mFragment = new AudioSharingDialogFragment();
+ mParent = new Fragment();
+ FragmentController.setupFragment(
+ mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null);
+ }
+
+ @Test
+ public void onCreateDialog_flagOff_dialogNotExist() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, new ArrayList<>(), (item) -> {});
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNull();
+ }
+
+ @Test
+ public void onCreateDialog_flagOn_noConnectedDevice() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, new ArrayList<>(), (item) -> {});
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ TextView description = dialog.findViewById(R.id.description_text);
+ ImageView image = dialog.findViewById(R.id.description_image);
+ Button shareBtn = dialog.findViewById(R.id.positive_btn);
+ Button cancelBtn = dialog.findViewById(R.id.negative_btn);
+ assertThat(dialog.isShowing()).isTrue();
+ assertThat(description.getVisibility()).isEqualTo(View.VISIBLE);
+ assertThat(description.getText().toString())
+ .isEqualTo(mParent.getString(R.string.audio_sharing_dialog_connect_device_content));
+ assertThat(image.getVisibility()).isEqualTo(View.VISIBLE);
+ assertThat(shareBtn.getVisibility()).isEqualTo(View.GONE);
+ assertThat(cancelBtn.getVisibility()).isEqualTo(View.GONE);
+ }
+
+ @Test
+ public void onCreateDialog_noConnectedDevice_dialogDismiss() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, new ArrayList<>(), (item) -> {});
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ dialog.findViewById(android.R.id.button2).performClick();
+ shadowMainLooper().idle();
+
+ assertThat(dialog.isShowing()).isFalse();
+ }
+
+ @Test
+ public void onCreateDialog_flagOn_singleConnectedDevice() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ ArrayList<AudioSharingDeviceItem> list = new ArrayList<>();
+ list.add(TEST_DEVICE_ITEM1);
+ mFragment.show(mParent, list, (item) -> {});
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ TextView title = dialog.findViewById(R.id.title_text);
+ TextView description = dialog.findViewById(R.id.description_text);
+ ImageView image = dialog.findViewById(R.id.description_image);
+ Button shareBtn = dialog.findViewById(R.id.positive_btn);
+ Button cancelBtn = dialog.findViewById(R.id.negative_btn);
+ assertThat(dialog.isShowing()).isTrue();
+ assertThat(title.getText().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_share_with_dialog_title, TEST_DEVICE_NAME1));
+ assertThat(description.getVisibility()).isEqualTo(View.VISIBLE);
+ assertThat(description.getText().toString())
+ .isEqualTo(mParent.getString(R.string.audio_sharing_dialog_share_content));
+ assertThat(image.getVisibility()).isEqualTo(View.GONE);
+ assertThat(shareBtn.getVisibility()).isEqualTo(View.VISIBLE);
+ assertThat(cancelBtn.getVisibility()).isEqualTo(View.VISIBLE);
+ }
+
+ @Test
+ public void onCreateDialog_singleConnectedDevice_dialogDismiss() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ ArrayList<AudioSharingDeviceItem> list = new ArrayList<>();
+ list.add(TEST_DEVICE_ITEM1);
+ mFragment.show(mParent, list, (item) -> {});
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ dialog.findViewById(R.id.negative_btn).performClick();
+ assertThat(dialog.isShowing()).isFalse();
+ }
+
+ @Test
+ public void onCreateDialog_singleConnectedDevice_shareClicked() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ ArrayList<AudioSharingDeviceItem> list = new ArrayList<>();
+ list.add(TEST_DEVICE_ITEM1);
+ AtomicBoolean isShareBtnClicked = new AtomicBoolean(false);
+ mFragment.show(mParent, list, (item) -> isShareBtnClicked.set(true));
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ dialog.findViewById(R.id.positive_btn).performClick();
+ assertThat(dialog.isShowing()).isFalse();
+ assertThat(isShareBtnClicked.get()).isTrue();
+ }
+
+ @Test
+ public void onCreateDialog_flagOn_multipleConnectedDevice() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ ArrayList<AudioSharingDeviceItem> list = new ArrayList<>();
+ list.add(TEST_DEVICE_ITEM1);
+ list.add(TEST_DEVICE_ITEM2);
+ list.add(TEST_DEVICE_ITEM3);
+ mFragment.show(mParent, list, (item) -> {});
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ TextView description = dialog.findViewById(R.id.description_text);
+ ImageView image = dialog.findViewById(R.id.description_image);
+ Button shareBtn = dialog.findViewById(R.id.positive_btn);
+ Button cancelBtn = dialog.findViewById(R.id.negative_btn);
+ RecyclerView recyclerView = dialog.findViewById(R.id.device_btn_list);
+ assertThat(dialog.isShowing()).isTrue();
+ assertThat(description.getVisibility()).isEqualTo(View.VISIBLE);
+ assertThat(description.getText().toString())
+ .isEqualTo(mParent.getString(R.string.audio_sharing_dialog_share_more_content));
+ assertThat(image.getVisibility()).isEqualTo(View.GONE);
+ assertThat(shareBtn.getVisibility()).isEqualTo(View.GONE);
+ assertThat(cancelBtn.getVisibility()).isEqualTo(View.VISIBLE);
+ assertThat(recyclerView.getVisibility()).isEqualTo(View.VISIBLE);
+ assertThat(recyclerView.getAdapter().getItemCount()).isEqualTo(3);
+ }
+
+ @Test
+ public void onCreateDialog_multipleConnectedDevice_dialogDismiss() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ ArrayList<AudioSharingDeviceItem> list = new ArrayList<>();
+ list.add(TEST_DEVICE_ITEM1);
+ list.add(TEST_DEVICE_ITEM2);
+ list.add(TEST_DEVICE_ITEM3);
+ mFragment.show(mParent, list, (item) -> {});
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ dialog.findViewById(R.id.negative_btn).performClick();
+ assertThat(dialog.isShowing()).isFalse();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandlerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandlerTest.java
new file mode 100644
index 0000000..570af1f
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandlerTest.java
@@ -0,0 +1,385 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.Context;
+import android.os.Looper;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+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.LocalBluetoothProfileManager;
+import com.android.settingslib.flags.Flags;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Correspondence;
+
+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 org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.androidx.fragment.FragmentController;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowBluetoothAdapter.class,
+ ShadowBluetoothUtils.class,
+ })
+public class AudioSharingDialogHandlerTest {
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ private static final String TEST_DEVICE_NAME1 = "test1";
+ private static final String TEST_DEVICE_NAME2 = "test2";
+ private static final String TEST_DEVICE_NAME3 = "test3";
+ private static final String TEST_DEVICE_NAME4 = "test4";
+ private static final String TEST_DEVICE_ADDRESS = "xx:xx:xx:xx";
+ private static final Correspondence<Fragment, String> TAG_EQUALS =
+ Correspondence.from(
+ (Fragment fragment, String tag) ->
+ fragment instanceof DialogFragment
+ && ((DialogFragment) fragment).getTag().equals(tag),
+ "is equal to");
+
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private LocalBluetoothProfileManager mLocalBtProfileManager;
+ @Mock private CachedBluetoothDeviceManager mCacheManager;
+ @Mock private LocalBluetoothLeBroadcast mBroadcast;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Mock private CachedBluetoothDevice mCachedDevice1;
+ @Mock private CachedBluetoothDevice mCachedDevice2;
+ @Mock private CachedBluetoothDevice mCachedDevice3;
+ @Mock private CachedBluetoothDevice mCachedDevice4;
+ @Mock private BluetoothDevice mDevice1;
+ @Mock private BluetoothDevice mDevice2;
+ @Mock private BluetoothDevice mDevice3;
+ @Mock private BluetoothDevice mDevice4;
+ @Mock private LeAudioProfile mLeAudioProfile;
+ private Fragment mParentFragment;
+ @Mock private BluetoothLeBroadcastReceiveState mState;
+ private Context mContext;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private AudioSharingDialogHandler mHandler;
+
+ @Before
+ public void setup() {
+ mContext = ApplicationProvider.getApplicationContext();
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mLocalBtManager = Utils.getLocalBtManager(mContext);
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mLocalBtManager.getProfileManager()).thenReturn(mLocalBtProfileManager);
+ when(mLocalBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast);
+ when(mLocalBtProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ List<Long> bisSyncState = new ArrayList<>();
+ bisSyncState.add(1L);
+ when(mState.getBisSyncState()).thenReturn(bisSyncState);
+ when(mLeAudioProfile.isEnabled(any())).thenReturn(true);
+ when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1);
+ when(mCachedDevice1.getDevice()).thenReturn(mDevice1);
+ when(mCachedDevice1.getProfiles()).thenReturn(List.of(mLeAudioProfile));
+ when(mCachedDevice1.getGroupId()).thenReturn(1);
+ when(mCachedDevice2.getName()).thenReturn(TEST_DEVICE_NAME2);
+ when(mCachedDevice2.getAddress()).thenReturn(TEST_DEVICE_ADDRESS);
+ when(mCachedDevice2.getDevice()).thenReturn(mDevice2);
+ when(mCachedDevice2.getProfiles()).thenReturn(List.of());
+ when(mCachedDevice2.getGroupId()).thenReturn(2);
+ when(mCachedDevice3.getName()).thenReturn(TEST_DEVICE_NAME3);
+ when(mCachedDevice3.getDevice()).thenReturn(mDevice3);
+ when(mCachedDevice3.getProfiles()).thenReturn(List.of(mLeAudioProfile));
+ when(mCachedDevice3.getGroupId()).thenReturn(3);
+ when(mCachedDevice4.getName()).thenReturn(TEST_DEVICE_NAME4);
+ when(mCachedDevice4.getDevice()).thenReturn(mDevice4);
+ when(mCachedDevice4.getProfiles()).thenReturn(List.of(mLeAudioProfile));
+ when(mCachedDevice4.getGroupId()).thenReturn(4);
+ when(mLocalBtManager.getCachedDeviceManager()).thenReturn(mCacheManager);
+ when(mCacheManager.findDevice(mDevice1)).thenReturn(mCachedDevice1);
+ when(mCacheManager.findDevice(mDevice2)).thenReturn(mCachedDevice2);
+ when(mCacheManager.findDevice(mDevice3)).thenReturn(mCachedDevice3);
+ when(mCacheManager.findDevice(mDevice4)).thenReturn(mCachedDevice4);
+ mParentFragment = new Fragment();
+ FragmentController.setupFragment(
+ mParentFragment,
+ FragmentActivity.class,
+ 0 /* containerViewId */,
+ null /* bundle */);
+ mHandler = new AudioSharingDialogHandler(mContext, mParentFragment);
+ }
+
+ @Test
+ public void handleUserTriggeredNonLeaDeviceConnected_noSharing_setActive() {
+ setUpBroadcast(false);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice2);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of());
+ mHandler.handleDeviceConnected(mCachedDevice2, /* userTriggered= */ true);
+ shadowOf(Looper.getMainLooper()).idle();
+ verify(mCachedDevice2).setActive();
+ }
+
+ @Test
+ public void handleUserTriggeredNonLeaDeviceConnected_sharing_showStopDialog() {
+ setUpBroadcast(true);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice2);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mState));
+ mHandler.handleDeviceConnected(mCachedDevice2, /* userTriggered= */ true);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments())
+ .comparingElementsUsing(TAG_EQUALS)
+ .containsExactly(AudioSharingStopDialogFragment.tag());
+ }
+
+ @Test
+ public void handleUserTriggeredLeaDeviceConnected_noSharingNoTwoLeaDevices_setActive() {
+ setUpBroadcast(false);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice1);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of());
+ mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ true);
+ shadowOf(Looper.getMainLooper()).idle();
+ verify(mCachedDevice1).setActive();
+ }
+
+ @Test
+ public void handleUserTriggeredLeaDeviceConnected_noSharingTwoLeaDevices_showJoinDialog() {
+ setUpBroadcast(false);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice1, mDevice3);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of());
+ mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ true);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments())
+ .comparingElementsUsing(TAG_EQUALS)
+ .containsExactly(AudioSharingJoinDialogFragment.tag());
+ }
+
+ @Test
+ public void handleUserTriggeredLeaDeviceConnected_sharing_showJoinDialog() {
+ setUpBroadcast(true);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice1, mDevice3);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(mDevice1)).thenReturn(ImmutableList.of());
+ when(mAssistant.getAllSources(mDevice3)).thenReturn(ImmutableList.of(mState));
+ mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ true);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments())
+ .comparingElementsUsing(TAG_EQUALS)
+ .containsExactly(AudioSharingJoinDialogFragment.tag());
+ }
+
+ @Test
+ public void
+ handleUserTriggeredLeaDeviceConnected_sharingWithTwoLeaDevices_showDisconnectDialog() {
+ setUpBroadcast(true);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice1, mDevice3, mDevice4);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(mDevice1)).thenReturn(ImmutableList.of());
+ when(mAssistant.getAllSources(mDevice3)).thenReturn(ImmutableList.of(mState));
+ when(mAssistant.getAllSources(mDevice4)).thenReturn(ImmutableList.of(mState));
+ mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ true);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments())
+ .comparingElementsUsing(TAG_EQUALS)
+ .containsExactly(AudioSharingDisconnectDialogFragment.tag());
+ }
+
+ @Test
+ public void handleNonLeaDeviceConnected_noSharing_doNothing() {
+ setUpBroadcast(false);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice2);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of());
+ mHandler.handleDeviceConnected(mCachedDevice2, /* userTriggered= */ false);
+ shadowOf(Looper.getMainLooper()).idle();
+ verify(mCachedDevice2, times(0)).setActive();
+ }
+
+ @Test
+ public void handleNonLeaDeviceConnected_sharing_showStopDialog() {
+ setUpBroadcast(true);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice2);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mState));
+ mHandler.handleDeviceConnected(mCachedDevice2, /* userTriggered= */ false);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments())
+ .comparingElementsUsing(TAG_EQUALS)
+ .containsExactly(AudioSharingStopDialogFragment.tag());
+ }
+
+ @Test
+ public void handleLeaDeviceConnected_noSharingNoTwoLeaDevices_doNothing() {
+ setUpBroadcast(false);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice1);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of());
+ mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ false);
+ shadowOf(Looper.getMainLooper()).idle();
+ verify(mCachedDevice1, times(0)).setActive();
+ }
+
+ @Test
+ public void handleLeaDeviceConnected_noSharingTwoLeaDevices_showJoinDialog() {
+ setUpBroadcast(false);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice1, mDevice3);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of());
+ mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ false);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments())
+ .comparingElementsUsing(TAG_EQUALS)
+ .containsExactly(AudioSharingJoinDialogFragment.tag());
+ }
+
+ @Test
+ public void handleLeaDeviceConnected_sharing_showJoinDialog() {
+ setUpBroadcast(true);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice1, mDevice3);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(mDevice1)).thenReturn(ImmutableList.of());
+ when(mAssistant.getAllSources(mDevice3)).thenReturn(ImmutableList.of(mState));
+ mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ false);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments())
+ .comparingElementsUsing(TAG_EQUALS)
+ .containsExactly(AudioSharingJoinDialogFragment.tag());
+ }
+
+ @Test
+ public void handleLeaDeviceConnected_sharingWithTwoLeaDevices_showDisconnectDialog() {
+ setUpBroadcast(true);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice1, mDevice3, mDevice4);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(mDevice1)).thenReturn(ImmutableList.of());
+ when(mAssistant.getAllSources(mDevice3)).thenReturn(ImmutableList.of(mState));
+ when(mAssistant.getAllSources(mDevice4)).thenReturn(ImmutableList.of(mState));
+ mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ false);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments())
+ .comparingElementsUsing(TAG_EQUALS)
+ .containsExactly(AudioSharingDisconnectDialogFragment.tag());
+ }
+
+ @Test
+ public void closeOpeningDialogsForLeaDevice_closeJoinDialog() {
+ // Show join dialog
+ setUpBroadcast(false);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice1, mDevice3);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of());
+ mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ true);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments())
+ .comparingElementsUsing(TAG_EQUALS)
+ .containsExactly(AudioSharingJoinDialogFragment.tag());
+ // Close opening dialogs
+ mHandler.closeOpeningDialogsForLeaDevice(mCachedDevice1);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments()).isEmpty();
+ }
+
+ @Test
+ public void closeOpeningDialogsForNonLeaDevice_closeStopDialog() {
+ // Show stop dialog
+ setUpBroadcast(true);
+ ImmutableList<BluetoothDevice> deviceList = ImmutableList.of(mDevice2);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(deviceList);
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mState));
+ mHandler.handleDeviceConnected(mCachedDevice2, /* userTriggered= */ true);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments())
+ .comparingElementsUsing(TAG_EQUALS)
+ .containsExactly(AudioSharingStopDialogFragment.tag());
+ // Close opening dialogs
+ mHandler.closeOpeningDialogsForNonLeaDevice(mCachedDevice2);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mParentFragment.getChildFragmentManager().getFragments()).isEmpty();
+ }
+
+ private void setUpBroadcast(boolean isBroadcasting) {
+ when(mBroadcast.isEnabled(any())).thenReturn(isBroadcasting);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHelperTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHelperTest.java
new file mode 100644
index 0000000..53de62e
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHelperTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.graphics.Typeface;
+import android.util.TypedValue;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.FragmentManager;
+
+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;
+
+@RunWith(RobolectricTestRunner.class)
+public class AudioSharingDialogHelperTest {
+ private static final String TAG = "test";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+
+ @Mock FragmentManager mFragmentManager;
+ @Mock DialogFragment mFragment;
+ @Mock AlertDialog mDialog;
+ @Mock TextView mTextView;
+
+ @Test
+ public void updateMessageStyle_updateStyle() {
+ when(mDialog.findViewById(android.R.id.message)).thenReturn(mTextView);
+ AudioSharingDialogHelper.updateMessageStyle(mDialog);
+ Typeface typeface = Typeface.create(Typeface.DEFAULT_FAMILY, Typeface.NORMAL);
+ verify(mTextView).setTypeface(typeface);
+ verify(mTextView).setTextDirection(View.TEXT_DIRECTION_LOCALE);
+ verify(mTextView).setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
+ verify(mTextView).setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14);
+ }
+
+ @Test
+ public void getDialogIfShowing_notShowing_returnNull() {
+ when(mFragmentManager.findFragmentByTag(TAG)).thenReturn(mFragment);
+ when(mFragment.getDialog()).thenReturn(mDialog);
+ when(mDialog.isShowing()).thenReturn(false);
+ assertThat(AudioSharingDialogHelper.getDialogIfShowing(mFragmentManager, TAG)).isNull();
+ }
+
+ @Test
+ public void getDialogIfShowing_showing_returnDialog() {
+ when(mFragmentManager.findFragmentByTag(TAG)).thenReturn(mFragment);
+ when(mFragment.getDialog()).thenReturn(mDialog);
+ when(mDialog.isShowing()).thenReturn(true);
+ assertThat(AudioSharingDialogHelper.getDialogIfShowing(mFragmentManager, TAG))
+ .isEqualTo(mDialog);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragmentTest.java
new file mode 100644
index 0000000..348efbe
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragmentTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothStatusCodes;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.widget.Button;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.android.settings.R;
+import com.android.settings.testutils.shadow.ShadowAlertDialogCompat;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.flags.Flags;
+
+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 org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.androidx.fragment.FragmentController;
+
+import java.util.ArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowAlertDialogCompat.class,
+ ShadowBluetoothAdapter.class,
+ })
+public class AudioSharingDisconnectDialogFragmentTest {
+
+ @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ private static final String TEST_DEVICE_NAME1 = "test1";
+ private static final String TEST_DEVICE_NAME2 = "test2";
+ private static final String TEST_DEVICE_NAME3 = "test3";
+ private static final int TEST_GROUP_ID1 = 1;
+ private static final int TEST_GROUP_ID2 = 2;
+ private static final int TEST_GROUP_ID3 = 3;
+ private static final String TEST_ADDRESS1 = "XX:11";
+ private static final String TEST_ADDRESS3 = "XX:33";
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM1 =
+ new AudioSharingDeviceItem(TEST_DEVICE_NAME1, TEST_GROUP_ID1, /* isActive= */ true);
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM2 =
+ new AudioSharingDeviceItem(TEST_DEVICE_NAME2, TEST_GROUP_ID2, /* isActive= */ false);
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM3 =
+ new AudioSharingDeviceItem(TEST_DEVICE_NAME3, TEST_GROUP_ID3, /* isActive= */ false);
+
+ @Mock private BluetoothDevice mDevice1;
+ @Mock private BluetoothDevice mDevice3;
+
+ @Mock private CachedBluetoothDevice mCachedDevice1;
+ @Mock private CachedBluetoothDevice mCachedDevice3;
+ private Fragment mParent;
+ private AudioSharingDisconnectDialogFragment mFragment;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private ArrayList<AudioSharingDeviceItem> mDeviceItems = new ArrayList<>();
+
+ @Before
+ public void setUp() {
+ AlertDialog latestAlertDialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ if (latestAlertDialog != null) {
+ latestAlertDialog.dismiss();
+ ShadowAlertDialogCompat.reset();
+ }
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ when(mDevice1.getAnonymizedAddress()).thenReturn(TEST_ADDRESS1);
+ when(mDevice3.getAnonymizedAddress()).thenReturn(TEST_ADDRESS3);
+ when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1);
+ when(mCachedDevice1.getDevice()).thenReturn(mDevice1);
+ when(mCachedDevice1.getGroupId()).thenReturn(TEST_GROUP_ID1);
+ when(mCachedDevice3.getName()).thenReturn(TEST_DEVICE_NAME3);
+ when(mCachedDevice3.getDevice()).thenReturn(mDevice3);
+ when(mCachedDevice3.getGroupId()).thenReturn(TEST_GROUP_ID3);
+ mFragment = new AudioSharingDisconnectDialogFragment();
+ mParent = new Fragment();
+ FragmentController.setupFragment(
+ mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null);
+ }
+
+ @Test
+ public void onCreateDialog_flagOff_dialogNotExist() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mDeviceItems = new ArrayList<>();
+ mDeviceItems.add(TEST_DEVICE_ITEM1);
+ mDeviceItems.add(TEST_DEVICE_ITEM2);
+ mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> {});
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNull();
+ }
+
+ @Test
+ public void onCreateDialog_flagOn_dialogShowBtnForTwoDevices() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mDeviceItems = new ArrayList<>();
+ mDeviceItems.add(TEST_DEVICE_ITEM1);
+ mDeviceItems.add(TEST_DEVICE_ITEM2);
+ mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> {});
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog.isShowing()).isTrue();
+ RecyclerView view = dialog.findViewById(R.id.device_btn_list);
+ assertThat(view.getAdapter().getItemCount()).isEqualTo(2);
+ }
+
+ @Test
+ public void onCreateDialog_dialogIsShowingForSameGroup_updateDialog() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mDeviceItems = new ArrayList<>();
+ mDeviceItems.add(TEST_DEVICE_ITEM1);
+ mDeviceItems.add(TEST_DEVICE_ITEM2);
+ mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> {});
+ shadowMainLooper().idle();
+ AtomicBoolean isItemBtnClicked = new AtomicBoolean(false);
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog.isShowing()).isTrue();
+ RecyclerView view = dialog.findViewById(R.id.device_btn_list);
+ assertThat(view.getAdapter().getItemCount()).isEqualTo(2);
+ Button btn1 =
+ view.findViewHolderForAdapterPosition(0).itemView.findViewById(R.id.device_button);
+ assertThat(btn1.getText().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_disconnect_device_button_label,
+ TEST_DEVICE_NAME1));
+ Button btn2 =
+ view.findViewHolderForAdapterPosition(1).itemView.findViewById(R.id.device_button);
+ assertThat(btn2.getText().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_disconnect_device_button_label,
+ TEST_DEVICE_NAME2));
+
+ // Update dialog content for device with same group
+ mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> isItemBtnClicked.set(true));
+ shadowMainLooper().idle();
+ dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog.isShowing()).isTrue();
+ btn1 = view.findViewHolderForAdapterPosition(0).itemView.findViewById(R.id.device_button);
+ btn1.performClick();
+ assertThat(isItemBtnClicked.get()).isTrue();
+ }
+
+ @Test
+ public void onCreateDialog_dialogIsShowingForNewGroup_updateDialog() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mDeviceItems = new ArrayList<>();
+ mDeviceItems.add(TEST_DEVICE_ITEM1);
+ mDeviceItems.add(TEST_DEVICE_ITEM2);
+ mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> {});
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog.isShowing()).isTrue();
+ RecyclerView view = dialog.findViewById(R.id.device_btn_list);
+ assertThat(view.getAdapter().getItemCount()).isEqualTo(2);
+
+ // Show new dialog for device with new group
+ ArrayList<AudioSharingDeviceItem> newDeviceItems = new ArrayList<>();
+ newDeviceItems.add(TEST_DEVICE_ITEM2);
+ newDeviceItems.add(TEST_DEVICE_ITEM3);
+ mFragment.show(mParent, newDeviceItems, mCachedDevice1, (item) -> {});
+ shadowMainLooper().idle();
+ dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog.isShowing()).isTrue();
+ view = dialog.findViewById(R.id.device_btn_list);
+ assertThat(view.getAdapter().getItemCount()).isEqualTo(2);
+ Button btn1 =
+ view.findViewHolderForAdapterPosition(0).itemView.findViewById(R.id.device_button);
+ assertThat(btn1.getText().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_disconnect_device_button_label,
+ TEST_DEVICE_NAME2));
+ Button btn2 =
+ view.findViewHolderForAdapterPosition(1).itemView.findViewById(R.id.device_button);
+ assertThat(btn2.getText().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_disconnect_device_button_label,
+ TEST_DEVICE_NAME3));
+ }
+
+ @Test
+ public void onCreateDialog_clickCancel_dialogDismiss() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mDeviceItems = new ArrayList<>();
+ mDeviceItems.add(TEST_DEVICE_ITEM1);
+ mDeviceItems.add(TEST_DEVICE_ITEM2);
+ mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> {});
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog.isShowing()).isTrue();
+ dialog.findViewById(R.id.negative_btn).performClick();
+ assertThat(dialog.isShowing()).isFalse();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingFeatureProviderImplTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingFeatureProviderImplTest.java
deleted file mode 100644
index 1965bff..0000000
--- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingFeatureProviderImplTest.java
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * 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.settings.connecteddevice.audiosharing;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import android.content.Context;
-
-import androidx.test.core.app.ApplicationProvider;
-
-import com.android.settings.connecteddevice.AvailableMediaDeviceGroupController;
-import com.android.settings.dashboard.DashboardFragment;
-import com.android.settingslib.bluetooth.CachedBluetoothDevice;
-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;
-
-@RunWith(RobolectricTestRunner.class)
-public class AudioSharingFeatureProviderImplTest {
- @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
-
- @Mock private CachedBluetoothDevice mCachedDevice;
- @Mock private LocalBluetoothManager mLocalBtManager;
- @Mock private DashboardFragment mFragment;
- private Context mContext;
- private AudioSharingFeatureProviderImpl mFeatureProvider;
-
- @Before
- public void setUp() {
- mContext = ApplicationProvider.getApplicationContext();
- mFeatureProvider = new AudioSharingFeatureProviderImpl();
- }
-
- @Test
- public void createAudioSharingDevicePreferenceController_returnsNull() {
- assertThat(
- mFeatureProvider.createAudioSharingDevicePreferenceController(
- mContext, mFragment, /* lifecycle= */ null))
- .isNull();
- }
-
- @Test
- public void createAvailableMediaDeviceGroupController_returnsNull() {
- assertThat(
- mFeatureProvider.createAvailableMediaDeviceGroupController(
- mContext, /* fragment= */ null, /* lifecycle= */ null))
- .isInstanceOf(AvailableMediaDeviceGroupController.class);
- }
-
- @Test
- public void isAudioSharingFilterMatched_returnsFalse() {
- assertThat(mFeatureProvider.isAudioSharingFilterMatched(mCachedDevice, mLocalBtManager))
- .isFalse();
- }
-}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragmentTest.java
new file mode 100644
index 0000000..5d43ccc
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragmentTest.java
@@ -0,0 +1,231 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothStatusCodes;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+
+import com.android.settings.R;
+import com.android.settings.testutils.shadow.ShadowAlertDialogCompat;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.flags.Flags;
+
+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 org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.androidx.fragment.FragmentController;
+
+import java.util.ArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowAlertDialogCompat.class,
+ ShadowBluetoothAdapter.class,
+ })
+public class AudioSharingJoinDialogFragmentTest {
+
+ @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ private static final String TEST_DEVICE_NAME1 = "test1";
+ private static final String TEST_DEVICE_NAME2 = "test2";
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM1 =
+ new AudioSharingDeviceItem(TEST_DEVICE_NAME1, /* groupId= */ 1, /* isActive= */ true);
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM2 =
+ new AudioSharingDeviceItem(TEST_DEVICE_NAME2, /* groupId= */ 2, /* isActive= */ false);
+ private static final AudioSharingJoinDialogFragment.DialogEventListener EMPTY_EVENT_LISTENER =
+ new AudioSharingJoinDialogFragment.DialogEventListener() {
+ @Override
+ public void onShareClick() {}
+
+ @Override
+ public void onCancelClick() {}
+ };
+
+ @Mock private CachedBluetoothDevice mCachedDevice1;
+ @Mock private CachedBluetoothDevice mCachedDevice2;
+ private Fragment mParent;
+ private AudioSharingJoinDialogFragment mFragment;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+
+ @Before
+ public void setUp() {
+ AlertDialog latestAlertDialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ if (latestAlertDialog != null) {
+ latestAlertDialog.dismiss();
+ ShadowAlertDialogCompat.reset();
+ }
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1);
+ when(mCachedDevice2.getName()).thenReturn(TEST_DEVICE_NAME2);
+ mFragment = new AudioSharingJoinDialogFragment();
+ mParent = new Fragment();
+ FragmentController.setupFragment(
+ mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null);
+ }
+
+ @Test
+ public void onCreateDialog_flagOff_dialogNotExist() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, new ArrayList<>(), mCachedDevice2, EMPTY_EVENT_LISTENER);
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNull();
+ }
+
+ @Test
+ public void onCreateDialog_flagOn_dialogShowTextForSingleDevice() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, new ArrayList<>(), mCachedDevice2, EMPTY_EVENT_LISTENER);
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+ ShadowAlertDialogCompat shadowDialog = ShadowAlertDialogCompat.shadowOf(dialog);
+ assertThat(shadowDialog.getMessage().toString()).isEqualTo(TEST_DEVICE_NAME2);
+ }
+
+ @Test
+ public void onCreateDialog_flagOn_dialogShowTextForTwoDevice() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ ArrayList<AudioSharingDeviceItem> list = new ArrayList<>();
+ list.add(TEST_DEVICE_ITEM1);
+ mFragment.show(mParent, list, mCachedDevice2, EMPTY_EVENT_LISTENER);
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+ ShadowAlertDialogCompat shadowDialog = ShadowAlertDialogCompat.shadowOf(dialog);
+ assertThat(shadowDialog.getMessage().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_share_dialog_subtitle,
+ TEST_DEVICE_NAME1,
+ TEST_DEVICE_NAME2));
+ }
+
+ @Test
+ public void onCreateDialog_dialogIsShowing_updateDialog() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ ArrayList<AudioSharingDeviceItem> list = new ArrayList<>();
+ list.add(TEST_DEVICE_ITEM1);
+ mFragment.show(mParent, list, mCachedDevice2, EMPTY_EVENT_LISTENER);
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+
+ // Update the content
+ ArrayList<AudioSharingDeviceItem> list2 = new ArrayList<>();
+ list2.add(TEST_DEVICE_ITEM2);
+ mFragment.show(mParent, list2, mCachedDevice1, EMPTY_EVENT_LISTENER);
+ shadowMainLooper().idle();
+ dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+ ShadowAlertDialogCompat shadowDialog = ShadowAlertDialogCompat.shadowOf(dialog);
+ assertThat(shadowDialog.getMessage().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_share_dialog_subtitle,
+ TEST_DEVICE_NAME2,
+ TEST_DEVICE_NAME1));
+ }
+
+ @Test
+ public void onCreateDialog_clickCancel_dialogDismiss() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, new ArrayList<>(), mCachedDevice2, EMPTY_EVENT_LISTENER);
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ dialog.findViewById(R.id.negative_btn).performClick();
+ assertThat(dialog.isShowing()).isFalse();
+ }
+
+ @Test
+ public void onCreateDialog_clickBtn_callbackTriggered() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ AtomicBoolean isShareBtnClicked = new AtomicBoolean(false);
+ mFragment.show(
+ mParent,
+ new ArrayList<>(),
+ mCachedDevice2,
+ new AudioSharingJoinDialogFragment.DialogEventListener() {
+ @Override
+ public void onShareClick() {
+ isShareBtnClicked.set(true);
+ }
+
+ @Override
+ public void onCancelClick() {}
+ });
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ dialog.findViewById(R.id.positive_btn).performClick();
+ assertThat(dialog.isShowing()).isFalse();
+ assertThat(isShareBtnClicked.get()).isTrue();
+ }
+
+ @Test
+ public void onCreateDialog_clickCancel_callbackTriggered() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ AtomicBoolean isCancelBtnClicked = new AtomicBoolean(false);
+ mFragment.show(
+ mParent,
+ new ArrayList<>(),
+ mCachedDevice2,
+ new AudioSharingJoinDialogFragment.DialogEventListener() {
+ @Override
+ public void onShareClick() {}
+
+ @Override
+ public void onCancelClick() {
+ isCancelBtnClicked.set(true);
+ }
+ });
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ dialog.findViewById(R.id.negative_btn).performClick();
+ assertThat(dialog.isShowing()).isFalse();
+ assertThat(isCancelBtnClicked.get()).isTrue();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPlaySoundPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPlaySoundPreferenceControllerTest.java
new file mode 100644
index 0000000..f811930
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPlaySoundPreferenceControllerTest.java
@@ -0,0 +1,185 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.android.settings.core.BasePreferenceController.AVAILABLE;
+import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.Context;
+import android.media.AudioAttributes;
+import android.media.AudioManager;
+import android.media.Ringtone;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.flags.Flags;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {ShadowBluetoothAdapter.class})
+public class AudioSharingPlaySoundPreferenceControllerTest {
+ private static final String PREF_KEY = "audio_sharing_play_sound";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+ @Spy Context mContext = ApplicationProvider.getApplicationContext();
+ @Mock private PreferenceScreen mScreen;
+ @Mock private Ringtone mRingtone;
+
+ private AudioSharingPlaySoundPreferenceController mController;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private Lifecycle mLifecycle;
+ private LifecycleOwner mLifecycleOwner;
+ private Preference mPreference;
+
+ @Before
+ public void setUp() {
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mLifecycleOwner = () -> mLifecycle;
+ mLifecycle = new Lifecycle(mLifecycleOwner);
+ when(mRingtone.getStreamType()).thenReturn(AudioManager.STREAM_MUSIC);
+ when(mRingtone.getAudioAttributes()).thenReturn(new AudioAttributes.Builder().build());
+ mController = new AudioSharingPlaySoundPreferenceController(mContext);
+ mController.setRingtone(mRingtone);
+ mPreference = new Preference(mContext);
+ when(mScreen.findPreference(PREF_KEY)).thenReturn(mPreference);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOn_available() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOff_unsupported() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE);
+ }
+
+ @Test
+ public void getAvailabilityStatus_nullRingtone_unsupported() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setRingtone(null);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE);
+ }
+
+ @Test
+ public void displayPreference_visible() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mPreference.setVisible(false);
+ mController.displayPreference(mScreen);
+ assertThat(mPreference.isVisible()).isTrue();
+ }
+
+ @Test
+ public void displayPreference_nullRingtone_invisible() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mPreference.setVisible(true);
+ mController.setRingtone(null);
+ mController.displayPreference(mScreen);
+ assertThat(mPreference.isVisible()).isFalse();
+ }
+
+ @Test
+ public void displayPreference_flagOff_invisible() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mPreference.setVisible(true);
+ mController.displayPreference(mScreen);
+ assertThat(mPreference.isVisible()).isFalse();
+ }
+
+ @Test
+ public void getPreferenceKey_returnsCorrectKey() {
+ assertThat(mController.getPreferenceKey()).isEqualTo(PREF_KEY);
+ }
+
+ @Test
+ public void clickPreference_ringtoneIsNull_doNothing() {
+ mController.setRingtone(null);
+ when(mRingtone.isPlaying()).thenReturn(false);
+ doNothing().when(mRingtone).play();
+ mController.displayPreference(mScreen);
+ mPreference.performClick();
+ verify(mRingtone, times(0)).play();
+ }
+
+ @Test
+ public void clickPreference_isPlaying_doNothing() {
+ when(mRingtone.isPlaying()).thenReturn(true);
+ doNothing().when(mRingtone).play();
+ mController.displayPreference(mScreen);
+ mPreference.performClick();
+ verify(mRingtone, times(0)).play();
+ }
+
+ @Test
+ public void clickPreference_notPlaying_play() {
+ when(mRingtone.isPlaying()).thenReturn(false);
+ doNothing().when(mRingtone).play();
+ mController.displayPreference(mScreen);
+ mPreference.performClick();
+ verify(mRingtone).play();
+ }
+
+ @Test
+ public void nonStop_isPlaying_stop() {
+ when(mRingtone.isPlaying()).thenReturn(true);
+ doNothing().when(mRingtone).stop();
+ mController.onStop(mLifecycleOwner);
+ verify(mRingtone).stop();
+ }
+
+ @Test
+ public void nonStop_notPlaying_doNothing() {
+ when(mRingtone.isPlaying()).thenReturn(false);
+ doNothing().when(mRingtone).stop();
+ mController.onStop(mLifecycleOwner);
+ verify(mRingtone, times(0)).stop();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceControllerTest.java
new file mode 100644
index 0000000..b8bee1a
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceControllerTest.java
@@ -0,0 +1,169 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static android.bluetooth.BluetoothAdapter.STATE_OFF;
+import static android.bluetooth.BluetoothAdapter.STATE_ON;
+
+import static com.android.settings.core.BasePreferenceController.AVAILABLE;
+import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothLeBroadcast;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.Context;
+import android.os.Looper;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+import com.android.settings.testutils.shadow.ShadowThreadUtils;
+import com.android.settingslib.bluetooth.BluetoothEventManager;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.flags.Flags;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowBluetoothAdapter.class,
+ ShadowBluetoothUtils.class,
+ ShadowThreadUtils.class
+ })
+public class AudioSharingPreferenceControllerTest {
+ private static final String PREF_KEY = "audio_sharing_settings";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ @Spy Context mContext = ApplicationProvider.getApplicationContext();
+ @Mock private PreferenceScreen mScreen;
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private BluetoothEventManager mBtEventManager;
+ @Mock private LocalBluetoothProfileManager mLocalBtProfileManager;
+ @Mock private LocalBluetoothLeBroadcast mBroadcast;
+ private AudioSharingPreferenceController mController;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private LocalBluetoothManager mLocalBluetoothManager;
+ private Lifecycle mLifecycle;
+ private LifecycleOwner mLifecycleOwner;
+ private Preference mPreference;
+
+ @Before
+ public void setUp() {
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mLifecycleOwner = () -> mLifecycle;
+ mLifecycle = new Lifecycle(mLifecycleOwner);
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
+ when(mLocalBluetoothManager.getEventManager()).thenReturn(mBtEventManager);
+ when(mLocalBluetoothManager.getProfileManager()).thenReturn(mLocalBtProfileManager);
+ when(mLocalBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast);
+ mController = new AudioSharingPreferenceController(mContext, PREF_KEY);
+ mPreference = new Preference(mContext);
+ when(mScreen.findPreference(PREF_KEY)).thenReturn(mPreference);
+ }
+
+ @Test
+ public void onStart_registerCallback() {
+ mController.onStart(mLifecycleOwner);
+ verify(mBtEventManager).registerCallback(mController);
+ verify(mBroadcast).registerServiceCallBack(any(), any(BluetoothLeBroadcast.Callback.class));
+ }
+
+ @Test
+ public void onStop_unregisterCallback() {
+ mController.onStop(mLifecycleOwner);
+ verify(mBtEventManager).unregisterCallback(mController);
+ verify(mBroadcast).unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class));
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOn() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOff() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE);
+ }
+
+ @Test
+ public void getSummary_broadcastOn() {
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ assertThat(mController.getSummary().toString())
+ .isEqualTo(mContext.getString(R.string.audio_sharing_summary_on));
+ }
+
+ @Test
+ public void getSummary_broadcastOff() {
+ when(mBroadcast.isEnabled(any())).thenReturn(false);
+ assertThat(mController.getSummary().toString())
+ .isEqualTo(mContext.getString(R.string.audio_sharing_summary_off));
+ }
+
+ @Test
+ public void onBluetoothStateChanged_refreshSummary() {
+ mController.displayPreference(mScreen);
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ mController.onBluetoothStateChanged(STATE_ON);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.getSummary().toString())
+ .isEqualTo(mContext.getString(R.string.audio_sharing_summary_on));
+
+ when(mBroadcast.isEnabled(any())).thenReturn(false);
+ mController.onBluetoothStateChanged(STATE_OFF);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.getSummary().toString())
+ .isEqualTo(mContext.getString(R.string.audio_sharing_summary_off));
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingReceiverTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingReceiverTest.java
new file mode 100644
index 0000000..d750297
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingReceiverTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast.ACTION_LE_AUDIO_SHARING_STATE_CHANGE;
+import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast.BROADCAST_STATE_OFF;
+import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast.BROADCAST_STATE_ON;
+import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast.EXTRA_LE_AUDIO_SHARING_STATE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+import com.android.settingslib.R;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.flags.Flags;
+
+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.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowApplication;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {ShadowBluetoothAdapter.class, ShadowBluetoothUtils.class})
+public class AudioSharingReceiverTest {
+ private static final String ACTION_LE_AUDIO_SHARING_STOP =
+ "com.android.settings.action.BLUETOOTH_LE_AUDIO_SHARING_STOP";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ private Context mContext;
+ private ShadowApplication mShadowApplication;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private LocalBluetoothManager mLocalBluetoothManager;
+ @Mock private LocalBluetoothProfileManager mLocalBtProfileManager;
+ @Mock private LocalBluetoothLeBroadcast mBroadcast;
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private NotificationManager mNm;
+
+ @Before
+ public void setUp() {
+ mContext = RuntimeEnvironment.getApplication();
+ mShadowApplication = Shadow.extract(mContext);
+ mShadowApplication.setSystemService(Context.NOTIFICATION_SERVICE, mNm);
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
+ when(mLocalBluetoothManager.getProfileManager()).thenReturn(mLocalBtProfileManager);
+ when(mLocalBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast);
+ }
+
+ @Test
+ public void broadcastReceiver_isRegistered() {
+ List<ShadowApplication.Wrapper> registeredReceivers =
+ mShadowApplication.getRegisteredReceivers();
+
+ int matchedCount =
+ registeredReceivers.stream()
+ .filter(
+ receiver ->
+ AudioSharingReceiver.class
+ .getSimpleName()
+ .equals(
+ receiver.broadcastReceiver
+ .getClass()
+ .getSimpleName()))
+ .collect(Collectors.toList())
+ .size();
+ assertThat(matchedCount).isEqualTo(1);
+ }
+
+ @Test
+ public void broadcastReceiver_receiveAudioSharingStateChangeIntentFlagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+
+ Intent intent = new Intent(ACTION_LE_AUDIO_SHARING_STATE_CHANGE);
+ intent.setPackage(mContext.getPackageName());
+ intent.putExtra(EXTRA_LE_AUDIO_SHARING_STATE, BROADCAST_STATE_ON);
+ AudioSharingReceiver audioSharingReceiver = getAudioSharingReceiver(intent);
+ audioSharingReceiver.onReceive(mContext, intent);
+
+ verifyNoInteractions(mNm);
+ }
+
+ @Test
+ public void broadcastReceiver_receiveAudioSharingStateChangeIntentNoState_doNothing() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+
+ Intent intent = new Intent(ACTION_LE_AUDIO_SHARING_STATE_CHANGE);
+ intent.setPackage(mContext.getPackageName());
+ AudioSharingReceiver audioSharingReceiver = getAudioSharingReceiver(intent);
+ audioSharingReceiver.onReceive(mContext, intent);
+
+ verifyNoInteractions(mNm);
+ }
+
+ @Test
+ public void broadcastReceiver_receiveAudioSharingStateChangeIntentOnState_showNotification() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+
+ Intent intent = new Intent(ACTION_LE_AUDIO_SHARING_STATE_CHANGE);
+ intent.setPackage(mContext.getPackageName());
+ intent.putExtra(EXTRA_LE_AUDIO_SHARING_STATE, BROADCAST_STATE_ON);
+ AudioSharingReceiver audioSharingReceiver = getAudioSharingReceiver(intent);
+ audioSharingReceiver.onReceive(mContext, intent);
+
+ verify(mNm, times(1))
+ .notify(eq(R.drawable.ic_bt_le_audio_sharing), any(Notification.class));
+ }
+
+ @Test
+ public void
+ broadcastReceiver_receiveAudioSharingStateChangeIntentOffState_cancelNotification() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+
+ Intent intent = new Intent(ACTION_LE_AUDIO_SHARING_STATE_CHANGE);
+ intent.setPackage(mContext.getPackageName());
+ intent.putExtra(EXTRA_LE_AUDIO_SHARING_STATE, BROADCAST_STATE_OFF);
+ AudioSharingReceiver audioSharingReceiver = getAudioSharingReceiver(intent);
+ audioSharingReceiver.onReceive(mContext, intent);
+
+ verify(mNm, times(1)).cancel(R.drawable.ic_bt_le_audio_sharing);
+ }
+
+ @Test
+ public void broadcastReceiver_receiveAudioSharingStopIntentFlagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+
+ Intent intent = new Intent(ACTION_LE_AUDIO_SHARING_STOP);
+ intent.setPackage(mContext.getPackageName());
+ AudioSharingReceiver audioSharingReceiver = getAudioSharingReceiver(intent);
+ audioSharingReceiver.onReceive(mContext, intent);
+
+ verifyNoInteractions(mBroadcast);
+ }
+
+ @Test
+ public void broadcastReceiver_receiveAudioSharingStopIntent_stopBroadcast() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ int broadcastId = 1;
+ when(mBroadcast.getLatestBroadcastId()).thenReturn(broadcastId);
+
+ Intent intent = new Intent(ACTION_LE_AUDIO_SHARING_STOP);
+ intent.setPackage(mContext.getPackageName());
+ AudioSharingReceiver audioSharingReceiver = getAudioSharingReceiver(intent);
+ audioSharingReceiver.onReceive(mContext, intent);
+
+ verify(mBroadcast, times(1)).stopBroadcast(broadcastId);
+ }
+
+ private AudioSharingReceiver getAudioSharingReceiver(Intent intent) {
+ assertThat(mShadowApplication.hasReceiverForIntent(intent)).isTrue();
+ List<BroadcastReceiver> receiversForIntent =
+ mShadowApplication.getReceiversForIntent(intent);
+ assertThat(receiversForIntent).hasSize(1);
+ BroadcastReceiver broadcastReceiver = receiversForIntent.get(0);
+ assertThat(broadcastReceiver).isInstanceOf(AudioSharingReceiver.class);
+ return (AudioSharingReceiver) broadcastReceiver;
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragmentTest.java
new file mode 100644
index 0000000..84d7a31
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragmentTest.java
@@ -0,0 +1,242 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothStatusCodes;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+
+import com.android.settings.R;
+import com.android.settings.testutils.shadow.ShadowAlertDialogCompat;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.flags.Flags;
+
+import com.google.common.collect.ImmutableList;
+
+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 org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.androidx.fragment.FragmentController;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowAlertDialogCompat.class,
+ ShadowBluetoothAdapter.class,
+ })
+public class AudioSharingStopDialogFragmentTest {
+
+ @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ private static final String TEST_DEVICE_NAME1 = "test1";
+ private static final String TEST_DEVICE_NAME2 = "test2";
+ private static final String TEST_DEVICE_NAME3 = "test3";
+ private static final int TEST_DEVICE_GROUP_ID1 = 1;
+ private static final int TEST_DEVICE_GROUP_ID2 = 2;
+ private static final int TEST_DEVICE_GROUP_ID3 = 3;
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM2 =
+ new AudioSharingDeviceItem(
+ TEST_DEVICE_NAME2, TEST_DEVICE_GROUP_ID2, /* isActive= */ false);
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM3 =
+ new AudioSharingDeviceItem(
+ TEST_DEVICE_NAME3, TEST_DEVICE_GROUP_ID3, /* isActive= */ false);
+
+ @Mock private CachedBluetoothDevice mCachedDevice1;
+ @Mock private CachedBluetoothDevice mCachedDevice2;
+ @Mock private BluetoothDevice mDevice1;
+ @Mock private BluetoothDevice mDevice2;
+ private Fragment mParent;
+ private AudioSharingStopDialogFragment mFragment;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+
+ @Before
+ public void setUp() {
+ AlertDialog latestAlertDialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ if (latestAlertDialog != null) {
+ latestAlertDialog.dismiss();
+ ShadowAlertDialogCompat.reset();
+ }
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1);
+ when(mCachedDevice1.getGroupId()).thenReturn(TEST_DEVICE_GROUP_ID1);
+ when(mCachedDevice1.getDevice()).thenReturn(mDevice1);
+ when(mCachedDevice2.getName()).thenReturn(TEST_DEVICE_NAME2);
+ when(mCachedDevice2.getGroupId()).thenReturn(TEST_DEVICE_GROUP_ID2);
+ when(mCachedDevice2.getDevice()).thenReturn(mDevice2);
+ mFragment = new AudioSharingStopDialogFragment();
+ mParent = new Fragment();
+ FragmentController.setupFragment(
+ mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null);
+ }
+
+ @Test
+ public void onCreateDialog_flagOff_dialogNotExist() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, ImmutableList.of(), mCachedDevice1, () -> {});
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNull();
+ }
+
+ @Test
+ public void onCreateDialog_oneDeviceInSharing_showDialogWithCorrectMessage() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, ImmutableList.of(TEST_DEVICE_ITEM2), mCachedDevice1, () -> {});
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+ TextView view = dialog.findViewById(R.id.description_text);
+ assertThat(view.getText().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_stop_dialog_content, TEST_DEVICE_NAME2));
+ }
+
+ @Test
+ public void onCreateDialog_twoDeviceInSharing_showDialogWithCorrectMessage() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(
+ mParent,
+ ImmutableList.of(TEST_DEVICE_ITEM2, TEST_DEVICE_ITEM3),
+ mCachedDevice1,
+ () -> {});
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+ TextView view = dialog.findViewById(R.id.description_text);
+ assertThat(view.getText().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_stop_dialog_with_two_content,
+ TEST_DEVICE_NAME2,
+ TEST_DEVICE_NAME3));
+ }
+
+ @Test
+ public void onCreateDialog_dialogIsShowingForSameDevice_updateDialog() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, ImmutableList.of(), mCachedDevice1, () -> {});
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+ TextView view = dialog.findViewById(R.id.description_text);
+ assertThat(view.getText().toString())
+ .isEqualTo(mParent.getString(R.string.audio_sharing_stop_dialog_with_more_content));
+
+ // Update the content
+ AtomicBoolean isStopBtnClicked = new AtomicBoolean(false);
+ mFragment.show(
+ mParent, ImmutableList.of(), mCachedDevice1, () -> isStopBtnClicked.set(true));
+ shadowMainLooper().idle();
+ dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+
+ dialog.findViewById(android.R.id.button1).performClick();
+ shadowMainLooper().idle();
+ assertThat(dialog.isShowing()).isFalse();
+ assertThat(isStopBtnClicked.get()).isTrue();
+ }
+
+ @Test
+ public void onCreateDialog_dialogIsShowingForNewDevice_showNewDialog() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, ImmutableList.of(), mCachedDevice1, () -> {});
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+ TextView view = dialog.findViewById(R.id.description_text);
+ assertThat(view.getText().toString())
+ .isEqualTo(mParent.getString(R.string.audio_sharing_stop_dialog_with_more_content));
+ TextView title = dialog.findViewById(R.id.title_text);
+ assertThat(title.getText().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_stop_dialog_title, TEST_DEVICE_NAME1));
+
+ // Show new dialog
+ mFragment.show(mParent, ImmutableList.of(), mCachedDevice2, () -> {});
+ shadowMainLooper().idle();
+ dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+ view = dialog.findViewById(R.id.description_text);
+ assertThat(view.getText().toString())
+ .isEqualTo(mParent.getString(R.string.audio_sharing_stop_dialog_with_more_content));
+ title = dialog.findViewById(R.id.title_text);
+ assertThat(title.getText().toString())
+ .isEqualTo(
+ mParent.getString(
+ R.string.audio_sharing_stop_dialog_title, TEST_DEVICE_NAME2));
+ }
+
+ @Test
+ public void onCreateDialog_clickCancel_dialogDismiss() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, ImmutableList.of(), mCachedDevice1, () -> {});
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ dialog.findViewById(android.R.id.button2).performClick();
+ shadowMainLooper().idle();
+ assertThat(dialog.isShowing()).isFalse();
+ }
+
+ @Test
+ public void onCreateDialog_clickShare_callbackTriggered() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ AtomicBoolean isStopBtnClicked = new AtomicBoolean(false);
+ mFragment.show(
+ mParent, ImmutableList.of(), mCachedDevice1, () -> isStopBtnClicked.set(true));
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ dialog.findViewById(android.R.id.button1).performClick();
+ shadowMainLooper().idle();
+ assertThat(dialog.isShowing()).isFalse();
+ assertThat(isStopBtnClicked.get()).isTrue();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarControllerTest.java
new file mode 100644
index 0000000..0b8f121
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarControllerTest.java
@@ -0,0 +1,365 @@
+/*
+ * 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 static com.android.settings.core.BasePreferenceController.AVAILABLE;
+import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcast;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Looper;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.widget.CompoundButton;
+
+import androidx.lifecycle.LifecycleOwner;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+import com.android.settings.testutils.shadow.ShadowThreadUtils;
+import com.android.settings.widget.SettingsMainSwitchBar;
+import com.android.settingslib.RestrictedLockUtils;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.flags.Flags;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+import java.util.concurrent.Executor;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowBluetoothAdapter.class,
+ ShadowBluetoothUtils.class,
+ ShadowThreadUtils.class,
+ })
+public class AudioSharingSwitchBarControllerTest {
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ @Spy Context mContext = ApplicationProvider.getApplicationContext();
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private CachedBluetoothDeviceManager mDeviceManager;
+ @Mock private LocalBluetoothProfileManager mBtProfileManager;
+ @Mock private LocalBluetoothLeBroadcast mBroadcast;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Mock private VolumeControlProfile mVolumeControl;
+ @Mock private CompoundButton mBtnView;
+ @Mock private CachedBluetoothDevice mCachedDevice;
+ @Mock private BluetoothDevice mDevice;
+ private SettingsMainSwitchBar mSwitchBar;
+ private AudioSharingSwitchBarController mController;
+ private AudioSharingSwitchBarController.OnAudioSharingStateChangedListener mListener;
+ private Lifecycle mLifecycle;
+ private LifecycleOwner mLifecycleOwner;
+ private boolean mOnAudioSharingStateChanged;
+ private boolean mOnAudioSharingServiceConnected;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private LocalBluetoothManager mLocalBluetoothManager;
+
+ @Before
+ public void setUp() {
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mLifecycleOwner = () -> mLifecycle;
+ mLifecycle = new Lifecycle(mLifecycleOwner);
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
+ when(mLocalBluetoothManager.getProfileManager()).thenReturn(mBtProfileManager);
+ when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(mDeviceManager);
+ when(mDeviceManager.findDevice(mDevice)).thenReturn(mCachedDevice);
+ when(mCachedDevice.getDevice()).thenReturn(mDevice);
+ when(mCachedDevice.getGroupId()).thenReturn(1);
+ when(mCachedDevice.getName()).thenReturn("test");
+ when(mBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast);
+ when(mBtProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ when(mBtProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControl);
+ when(mVolumeControl.isProfileReady()).thenReturn(true);
+ when(mBroadcast.isProfileReady()).thenReturn(true);
+ doNothing()
+ .when(mBroadcast)
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcast.Callback.class));
+ doNothing()
+ .when(mBroadcast)
+ .unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class));
+ when(mAssistant.isProfileReady()).thenReturn(true);
+ doNothing()
+ .when(mAssistant)
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ doNothing()
+ .when(mAssistant)
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ mSwitchBar = new SettingsMainSwitchBar(mContext);
+ mSwitchBar.setDisabledByAdmin(mock(RestrictedLockUtils.EnforcedAdmin.class));
+ mOnAudioSharingStateChanged = false;
+ mOnAudioSharingServiceConnected = false;
+ mListener =
+ new AudioSharingSwitchBarController.OnAudioSharingStateChangedListener() {
+ @Override
+ public void onAudioSharingStateChanged() {
+ mOnAudioSharingStateChanged = true;
+ }
+
+ @Override
+ public void onAudioSharingProfilesConnected() {
+ mOnAudioSharingServiceConnected = true;
+ }
+ };
+ mController = new AudioSharingSwitchBarController(mContext, mSwitchBar, mListener);
+ }
+
+ @Test
+ public void bluetoothOff_switchDisabled() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mSwitchBar.isEnabled()).isTrue();
+ mContext.registerReceiver(
+ mController.mReceiver,
+ mController.mIntentFilter,
+ Context.RECEIVER_EXPORTED_UNAUDITED);
+ mShadowBluetoothAdapter.setEnabled(false);
+ when(mBroadcast.isEnabled(null)).thenReturn(false);
+ Intent intent = new Intent(BluetoothAdapter.ACTION_STATE_CHANGED);
+ intent.putExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_OFF);
+ mContext.sendBroadcast(intent);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ assertThat(mSwitchBar.isEnabled()).isFalse();
+ assertThat(mSwitchBar.isChecked()).isFalse();
+ assertThat(mOnAudioSharingStateChanged).isTrue();
+ assertThat(mOnAudioSharingServiceConnected).isFalse();
+ }
+
+ @Test
+ public void onServiceConnected_switchEnabled() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isEnabled(null)).thenReturn(true);
+ mController.onServiceConnected();
+ shadowOf(Looper.getMainLooper()).idle();
+
+ assertThat(mSwitchBar.isEnabled()).isTrue();
+ assertThat(mSwitchBar.isChecked()).isTrue();
+ assertThat(mOnAudioSharingStateChanged).isTrue();
+ assertThat(mOnAudioSharingServiceConnected).isTrue();
+ verify(mBtProfileManager).removeServiceListener(mController);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOn() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOff() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE);
+ }
+
+ @Test
+ public void onStart_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mContext, times(0))
+ .registerReceiver(any(BroadcastReceiver.class), any(IntentFilter.class), anyInt());
+ verify(mBroadcast, times(0))
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcast.Callback.class));
+ verify(mAssistant, times(0))
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mBtProfileManager, times(0)).addServiceListener(mController);
+ }
+
+ @Test
+ public void onStart_flagOnProfileNotReady_registerProfileCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isEnabled(null)).thenReturn(false);
+ when(mBroadcast.isProfileReady()).thenReturn(false);
+ mController.onStart(mLifecycleOwner);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mContext)
+ .registerReceiver(any(BroadcastReceiver.class), any(IntentFilter.class), anyInt());
+ verify(mBroadcast, times(0))
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcast.Callback.class));
+ verify(mAssistant, times(0))
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mBtProfileManager).addServiceListener(mController);
+ assertThat(mSwitchBar.isChecked()).isFalse();
+ assertThat(mSwitchBar.isEnabled()).isFalse();
+ }
+
+ @Test
+ public void onStart_flagOn_registerCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isEnabled(null)).thenReturn(true);
+ mController.onStart(mLifecycleOwner);
+ shadowOf(Looper.getMainLooper()).idle();
+
+ verify(mContext)
+ .registerReceiver(any(BroadcastReceiver.class), any(IntentFilter.class), anyInt());
+ verify(mBroadcast)
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcast.Callback.class));
+ verify(mAssistant)
+ .registerServiceCallBack(
+ any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mBtProfileManager, times(0)).addServiceListener(mController);
+ assertThat(mSwitchBar.isChecked()).isTrue();
+ assertThat(mSwitchBar.isEnabled()).isTrue();
+ }
+
+ @Test
+ public void onStop_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStop(mLifecycleOwner);
+ verify(mContext, times(0)).unregisterReceiver(any(BroadcastReceiver.class));
+ verify(mBroadcast, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class));
+ verify(mAssistant, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ verify(mBtProfileManager, times(0)).removeServiceListener(mController);
+ }
+
+ @Test
+ public void onStop_flagOn_notRegistered_doNothing() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(false);
+ doNothing().when(mContext).unregisterReceiver(any(BroadcastReceiver.class));
+ mController.onStop(mLifecycleOwner);
+
+ verify(mContext).unregisterReceiver(any(BroadcastReceiver.class));
+ verify(mBtProfileManager).removeServiceListener(mController);
+ verify(mBroadcast, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class));
+ verify(mAssistant, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ }
+
+ @Test
+ public void onStop_flagOn_registered_unregisterCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(true);
+ mContext.registerReceiver(
+ mController.mReceiver,
+ mController.mIntentFilter,
+ Context.RECEIVER_EXPORTED_UNAUDITED);
+ mController.onStop(mLifecycleOwner);
+ verify(mContext).unregisterReceiver(mController.mReceiver);
+ verify(mBtProfileManager).removeServiceListener(mController);
+ verify(mBroadcast).unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class));
+ verify(mAssistant)
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ }
+
+ @Test
+ public void onCheckedChangedToChecked_sharing_doNothing() {
+ when(mBtnView.isEnabled()).thenReturn(true);
+ when(mBroadcast.isEnabled(null)).thenReturn(true);
+ mController.onCheckedChanged(mBtnView, /* isChecked= */ true);
+ verify(mBroadcast, times(0)).startPrivateBroadcast();
+ }
+
+ @Test
+ public void onCheckedChangedToChecked_noConnectedLeaDevices_notStartAudioSharing() {
+ when(mBtnView.isEnabled()).thenReturn(true);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(ImmutableList.of());
+ doNothing().when(mBroadcast).startPrivateBroadcast();
+ mController.onCheckedChanged(mBtnView, /* isChecked= */ true);
+ assertThat(mSwitchBar.isChecked()).isFalse();
+ verify(mBroadcast, times(0)).startPrivateBroadcast();
+ }
+
+ @Test
+ public void onCheckedChangedToChecked_notSharing_withConnectedLeaDevices_startAudioSharing() {
+ when(mBtnView.isEnabled()).thenReturn(true);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(ImmutableList.of(mDevice));
+ doNothing().when(mBroadcast).startPrivateBroadcast();
+ mController.onCheckedChanged(mBtnView, /* isChecked= */ true);
+ verify(mBroadcast).startPrivateBroadcast();
+ }
+
+ @Test
+ public void onCheckedChangedToUnChecked_notSharing_doNothing() {
+ when(mBtnView.isEnabled()).thenReturn(true);
+ when(mBroadcast.isEnabled(null)).thenReturn(false);
+ when(mBroadcast.getLatestBroadcastId()).thenReturn(1);
+ mController.onCheckedChanged(mBtnView, /* isChecked= */ false);
+ verify(mBroadcast, times(0)).stopBroadcast(anyInt());
+ }
+
+ @Test
+ public void onCheckedChangedToUnChecked_sharing_stopAudioSharing() {
+ when(mBtnView.isEnabled()).thenReturn(true);
+ when(mBroadcast.isEnabled(null)).thenReturn(true);
+ when(mBroadcast.getLatestBroadcastId()).thenReturn(1);
+ doNothing().when(mBroadcast).stopBroadcast(anyInt());
+ mController.onCheckedChanged(mBtnView, /* isChecked= */ false);
+ verify(mBroadcast).stopBroadcast(1);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsDialogFragmentTest.java
new file mode 100644
index 0000000..53e0d71
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsDialogFragmentTest.java
@@ -0,0 +1,105 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothStatusCodes;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+
+import com.android.settings.testutils.shadow.ShadowAlertDialogCompat;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settingslib.flags.Flags;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.androidx.fragment.FragmentController;
+
+import java.util.ArrayList;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowAlertDialogCompat.class,
+ ShadowBluetoothAdapter.class,
+ })
+public class CallsAndAlarmsDialogFragmentTest {
+ @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ private static final String TEST_DEVICE_NAME1 = "test1";
+ private static final String TEST_DEVICE_NAME2 = "test2";
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM1 =
+ new AudioSharingDeviceItem(TEST_DEVICE_NAME1, /* groupId= */ 1, /* isActive= */ true);
+
+ private static final AudioSharingDeviceItem TEST_DEVICE_ITEM2 =
+ new AudioSharingDeviceItem(TEST_DEVICE_NAME2, /* groupId= */ 1, /* isActive= */ true);
+
+ private Fragment mParent;
+ private CallsAndAlarmsDialogFragment mFragment;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+
+ @Before
+ public void setUp() {
+ ShadowAlertDialogCompat.reset();
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mFragment = new CallsAndAlarmsDialogFragment();
+ mParent = new Fragment();
+ FragmentController.setupFragment(
+ mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null);
+ }
+
+ @Test
+ public void onCreateDialog_flagOff_dialogNotExist() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mFragment.show(mParent, new ArrayList<>(), (item) -> {});
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog).isNull();
+ }
+
+ @Test
+ public void onCreateDialog_showCorrectItems() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ ArrayList<AudioSharingDeviceItem> deviceItemList = new ArrayList<>();
+ deviceItemList.add(TEST_DEVICE_ITEM1);
+ deviceItemList.add(TEST_DEVICE_ITEM2);
+ mFragment.show(mParent, deviceItemList, (item) -> {});
+ shadowMainLooper().idle();
+ AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog();
+ assertThat(dialog.getListView().getCount()).isEqualTo(2);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsPreferenceControllerTest.java
new file mode 100644
index 0000000..614cb5b
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/CallsAndAlarmsPreferenceControllerTest.java
@@ -0,0 +1,381 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID;
+import static com.android.settings.core.BasePreferenceController.AVAILABLE;
+import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothCsipSetCoordinator;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothLeBroadcastAssistant;
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.os.Looper;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.provider.Settings;
+
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.R;
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+import com.android.settings.testutils.shadow.ShadowThreadUtils;
+import com.android.settingslib.bluetooth.BluetoothEventManager;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.flags.Flags;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowBluetoothAdapter.class,
+ ShadowBluetoothUtils.class,
+ ShadowThreadUtils.class,
+ })
+public class CallsAndAlarmsPreferenceControllerTest {
+ private static final String PREF_KEY = "calls_and_alarms";
+ private static final String TEST_DEVICE_NAME1 = "test1";
+ private static final String TEST_DEVICE_NAME2 = "test2";
+ private static final int TEST_DEVICE_GROUP_ID1 = 1;
+ private static final int TEST_DEVICE_GROUP_ID2 = 2;
+
+ private static final String TEST_SETTINGS_KEY =
+ "bluetooth_le_broadcast_fallback_active_group_id";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ @Spy Context mContext = ApplicationProvider.getApplicationContext();
+ @Mock private PreferenceScreen mScreen;
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private BluetoothEventManager mBtEventManager;
+ @Mock private LocalBluetoothProfileManager mBtProfileManager;
+ @Mock private CachedBluetoothDeviceManager mCacheManager;
+ @Mock private LocalBluetoothLeBroadcast mBroadcast;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Mock private VolumeControlProfile mVolumeControl;
+ @Mock private BluetoothDevice mDevice1;
+ @Mock private BluetoothDevice mDevice2;
+ @Mock private BluetoothDevice mDevice3;
+ @Mock private CachedBluetoothDevice mCachedDevice1;
+ @Mock private CachedBluetoothDevice mCachedDevice2;
+ @Mock private CachedBluetoothDevice mCachedDevice3;
+ @Mock private BluetoothLeBroadcastReceiveState mState;
+ @Mock private ContentResolver mContentResolver;
+ private CallsAndAlarmsPreferenceController mController;
+ @Spy private ContentObserver mContentObserver;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private LocalBluetoothManager mBtManager;
+ private Lifecycle mLifecycle;
+ private LifecycleOwner mLifecycleOwner;
+ private Preference mPreference;
+
+ @Before
+ public void setUp() {
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mLifecycleOwner = () -> mLifecycle;
+ mLifecycle = new Lifecycle(mLifecycleOwner);
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mBtManager = Utils.getLocalBtManager(mContext);
+ when(mBtManager.getEventManager()).thenReturn(mBtEventManager);
+ when(mBtManager.getProfileManager()).thenReturn(mBtProfileManager);
+ when(mBtManager.getCachedDeviceManager()).thenReturn(mCacheManager);
+ when(mBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast);
+ when(mBtProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ when(mBtProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControl);
+ when(mBroadcast.isProfileReady()).thenReturn(true);
+ when(mAssistant.isProfileReady()).thenReturn(true);
+ when(mVolumeControl.isProfileReady()).thenReturn(true);
+ List<Long> bisSyncState = new ArrayList<>();
+ bisSyncState.add(1L);
+ when(mState.getBisSyncState()).thenReturn(bisSyncState);
+ when(mContext.getContentResolver()).thenReturn(mContentResolver);
+ mController = new CallsAndAlarmsPreferenceController(mContext);
+ mController.init(null);
+ mContentObserver = mController.getSettingsObserver();
+ mPreference = new Preference(mContext);
+ when(mScreen.findPreference(PREF_KEY)).thenReturn(mPreference);
+ }
+
+ @Test
+ public void onStart_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mBtEventManager, times(0)).registerCallback(mController);
+ verify(mContentResolver, times(0))
+ .registerContentObserver(
+ Settings.Secure.getUriFor(SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID),
+ false,
+ mContentObserver);
+ verify(mAssistant, times(0))
+ .registerServiceCallBack(any(), any(BluetoothLeBroadcastAssistant.Callback.class));
+ }
+
+ @Test
+ public void onStart_flagOn_registerCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mBtEventManager).registerCallback(mController);
+ verify(mContentResolver)
+ .registerContentObserver(
+ Settings.Secure.getUriFor(SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID),
+ false,
+ mContentObserver);
+ verify(mAssistant)
+ .registerServiceCallBack(any(), any(BluetoothLeBroadcastAssistant.Callback.class));
+ }
+
+ @Test
+ public void onStop_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(true);
+ mController.onStop(mLifecycleOwner);
+ verify(mBtEventManager, times(0)).unregisterCallback(mController);
+ verify(mContentResolver, times(0)).unregisterContentObserver(mContentObserver);
+ verify(mAssistant, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ }
+
+ @Test
+ public void onStop_flagOn_notRegistered_doNothing() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(false);
+ mController.onStop(mLifecycleOwner);
+ verify(mBtEventManager, times(0)).unregisterCallback(mController);
+ verify(mContentResolver, times(0)).unregisterContentObserver(mContentObserver);
+ verify(mAssistant, times(0))
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ }
+
+ @Test
+ public void onStop_flagOn_registered_unregisterCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.setCallbacksRegistered(true);
+ mController.onStop(mLifecycleOwner);
+ verify(mBtEventManager).unregisterCallback(mController);
+ verify(mContentResolver).unregisterContentObserver(mContentObserver);
+ verify(mAssistant)
+ .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class));
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOn() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOff() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE);
+ }
+
+ @Test
+ public void updateVisibility_flagOff_invisible() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ mController.displayPreference(mScreen);
+ mController.updateVisibility();
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isFalse();
+ }
+
+ @Test
+ public void updateVisibility_broadcastOffBluetoothOff_invisible() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isEnabled(any())).thenReturn(false);
+ mShadowBluetoothAdapter.setEnabled(false);
+ mController.displayPreference(mScreen);
+ mController.updateVisibility();
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isFalse();
+ }
+
+ @Test
+ public void updateVisibility_broadcastOnBluetoothOff_invisible() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ mShadowBluetoothAdapter.setEnabled(false);
+ mController.displayPreference(mScreen);
+ mController.updateVisibility();
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isFalse();
+ }
+
+ @Test
+ public void updateVisibility_broadcastOffBluetoothOn_invisible() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isEnabled(any())).thenReturn(false);
+ mController.displayPreference(mScreen);
+ mController.updateVisibility();
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isFalse();
+ }
+
+ @Test
+ public void updateVisibility_broadcastOnBluetoothOn_visible() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ mController.displayPreference(mScreen);
+ mController.updateVisibility();
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isTrue();
+ }
+
+ @Test
+ public void onProfileConnectionStateChanged_noDeviceInSharing_updateSummary() {
+ Settings.Secure.putInt(mContentResolver, TEST_SETTINGS_KEY, TEST_DEVICE_GROUP_ID1);
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(ImmutableList.of());
+ mController.displayPreference(mScreen);
+ mPreference.setSummary("test");
+ mController.onProfileConnectionStateChanged(
+ mCachedDevice1,
+ BluetoothAdapter.STATE_DISCONNECTED,
+ BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.getSummary().toString()).isEmpty();
+ }
+
+ @Test
+ public void onFallbackDeviceChanged_updateSummary() {
+ Settings.Secure.putInt(mContentResolver, TEST_SETTINGS_KEY, TEST_DEVICE_GROUP_ID1);
+ when(mCachedDevice1.getGroupId()).thenReturn(TEST_DEVICE_GROUP_ID1);
+ when(mCachedDevice1.getDevice()).thenReturn(mDevice1);
+ when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1);
+ when(mCacheManager.findDevice(mDevice1)).thenReturn(mCachedDevice1);
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(ImmutableList.of(mDevice1));
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mState));
+ mController.displayPreference(mScreen);
+ mContentObserver.onChange(true);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.getSummary().toString())
+ .isEqualTo(
+ mContext.getString(
+ R.string.audio_sharing_call_audio_description, TEST_DEVICE_NAME1));
+ }
+
+ @Test
+ public void displayPreference_showCorrectSummary() {
+ Settings.Secure.putInt(mContentResolver, TEST_SETTINGS_KEY, TEST_DEVICE_GROUP_ID1);
+ when(mCachedDevice1.getGroupId()).thenReturn(TEST_DEVICE_GROUP_ID1);
+ when(mCachedDevice1.getDevice()).thenReturn(mDevice1);
+ when(mCachedDevice2.getGroupId()).thenReturn(TEST_DEVICE_GROUP_ID1);
+ when(mCachedDevice2.getDevice()).thenReturn(mDevice2);
+ when(mCachedDevice1.getMemberDevice()).thenReturn(ImmutableSet.of(mCachedDevice2));
+ when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1);
+ when(mCachedDevice3.getGroupId()).thenReturn(TEST_DEVICE_GROUP_ID2);
+ when(mCachedDevice3.getDevice()).thenReturn(mDevice3);
+ when(mCachedDevice3.getName()).thenReturn(TEST_DEVICE_NAME2);
+ when(mCacheManager.findDevice(mDevice1)).thenReturn(mCachedDevice1);
+ when(mCacheManager.findDevice(mDevice2)).thenReturn(mCachedDevice2);
+ when(mCacheManager.findDevice(mDevice3)).thenReturn(mCachedDevice3);
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(ImmutableList.of(mDevice1, mDevice2, mDevice3));
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mState));
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.getSummary().toString())
+ .isEqualTo(
+ mContext.getString(
+ R.string.audio_sharing_call_audio_description, TEST_DEVICE_NAME1));
+ }
+
+ @Test
+ public void displayPreference_noFallbackDeviceInSharing_showEmptySummary() {
+ Settings.Secure.putInt(mContentResolver, TEST_SETTINGS_KEY, TEST_DEVICE_GROUP_ID2);
+ when(mCachedDevice1.getGroupId()).thenReturn(TEST_DEVICE_GROUP_ID1);
+ when(mCachedDevice1.getDevice()).thenReturn(mDevice1);
+ when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1);
+ when(mCacheManager.findDevice(mDevice1)).thenReturn(mCachedDevice1);
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(ImmutableList.of(mDevice1));
+ when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mState));
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.getSummary().toString()).isEmpty();
+ }
+
+ @Test
+ public void displayPreference_noFallbackDevice_showEmptySummary() {
+ Settings.Secure.putInt(
+ mContentResolver, TEST_SETTINGS_KEY, BluetoothCsipSetCoordinator.GROUP_ID_INVALID);
+ when(mBroadcast.isEnabled(any())).thenReturn(true);
+ when(mAssistant.getDevicesMatchingConnectionStates(
+ new int[] {BluetoothProfile.STATE_CONNECTED}))
+ .thenReturn(ImmutableList.of());
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.getSummary().toString()).isEmpty();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/StreamSettingsCategoryControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/StreamSettingsCategoryControllerTest.java
new file mode 100644
index 0000000..50dde0f
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/StreamSettingsCategoryControllerTest.java
@@ -0,0 +1,247 @@
+/*
+ * 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.settings.connecteddevice.audiosharing;
+
+import static com.android.settings.core.BasePreferenceController.AVAILABLE;
+import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothStatusCodes;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Looper;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import androidx.lifecycle.LifecycleOwner;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.bluetooth.Utils;
+import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
+import com.android.settings.testutils.shadow.ShadowBluetoothUtils;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
+import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
+import com.android.settingslib.bluetooth.VolumeControlProfile;
+import com.android.settingslib.core.lifecycle.Lifecycle;
+import com.android.settingslib.flags.Flags;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadow.api.Shadow;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowBluetoothAdapter.class,
+ ShadowBluetoothUtils.class,
+ })
+public class StreamSettingsCategoryControllerTest {
+ private static final String KEY = "audio_sharing_stream_settings_category";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+ @Spy Context mContext = ApplicationProvider.getApplicationContext();
+ @Mock private LocalBluetoothManager mLocalBtManager;
+ @Mock private LocalBluetoothProfileManager mBtProfileManager;
+ @Mock private LocalBluetoothLeBroadcast mBroadcast;
+ @Mock private LocalBluetoothLeBroadcastAssistant mAssistant;
+ @Mock private VolumeControlProfile mVolumeControl;
+ @Mock private PreferenceScreen mScreen;
+
+ private StreamSettingsCategoryController mController;
+ private Lifecycle mLifecycle;
+ private LifecycleOwner mLifecycleOwner;
+ private ShadowBluetoothAdapter mShadowBluetoothAdapter;
+ private LocalBluetoothManager mLocalBluetoothManager;
+ private Preference mPreference;
+
+ @Before
+ public void setUp() {
+ mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
+ mShadowBluetoothAdapter.setEnabled(true);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
+ BluetoothStatusCodes.FEATURE_SUPPORTED);
+ mLifecycleOwner = () -> mLifecycle;
+ mLifecycle = new Lifecycle(mLifecycleOwner);
+ ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager;
+ mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
+ when(mLocalBluetoothManager.getProfileManager()).thenReturn(mBtProfileManager);
+ when(mBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast);
+ when(mBtProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant);
+ when(mBtProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControl);
+ when(mBroadcast.isProfileReady()).thenReturn(true);
+ when(mAssistant.isProfileReady()).thenReturn(true);
+ when(mVolumeControl.isProfileReady()).thenReturn(true);
+ mController = new StreamSettingsCategoryController(mContext, KEY);
+ mPreference = new Preference(mContext);
+ when(mScreen.findPreference(KEY)).thenReturn(mPreference);
+ }
+
+ @Test
+ public void bluetoothOff_updateVisibility() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mContext.registerReceiver(
+ mController.mReceiver,
+ mController.mIntentFilter,
+ Context.RECEIVER_EXPORTED_UNAUDITED);
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isTrue();
+
+ mShadowBluetoothAdapter.setEnabled(false);
+ Intent intent = new Intent(BluetoothAdapter.ACTION_STATE_CHANGED);
+ intent.putExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_OFF);
+ mContext.sendBroadcast(intent);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isFalse();
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOn() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE);
+ }
+
+ @Test
+ public void getAvailabilityStatus_flagOff() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE);
+ }
+
+ @Test
+ public void onStart_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mContext, times(0))
+ .registerReceiver(any(BroadcastReceiver.class), any(IntentFilter.class), anyInt());
+ verify(mBtProfileManager, times(0)).addServiceListener(mController);
+ }
+
+ @Test
+ public void onStart_flagOn_registerCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStart(mLifecycleOwner);
+ verify(mContext)
+ .registerReceiver(any(BroadcastReceiver.class), any(IntentFilter.class), anyInt());
+ verify(mBtProfileManager, times(0)).addServiceListener(mController);
+ }
+
+ @Test
+ public void onStart_flagOnProfileNotReady_registerProfileManagerCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isProfileReady()).thenReturn(false);
+ mController.onStart(mLifecycleOwner);
+ verify(mContext)
+ .registerReceiver(any(BroadcastReceiver.class), any(IntentFilter.class), anyInt());
+ verify(mBtProfileManager).addServiceListener(mController);
+ }
+
+ @Test
+ public void onStop_flagOff_doNothing() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mController.onStop(mLifecycleOwner);
+ verify(mContext, times(0)).unregisterReceiver(any(BroadcastReceiver.class));
+ verify(mBtProfileManager, times(0)).removeServiceListener(mController);
+ }
+
+ @Test
+ public void onStop_flagOn_unregisterCallback() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ doNothing().when(mContext).unregisterReceiver(any(BroadcastReceiver.class));
+ mController.onStop(mLifecycleOwner);
+ verify(mContext).unregisterReceiver(any(BroadcastReceiver.class));
+ verify(mBtProfileManager).removeServiceListener(mController);
+ }
+
+ @Test
+ public void displayPreference_flagOff_preferenceInvisible() {
+ mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mPreference.setVisible(true);
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isFalse();
+ }
+
+ @Test
+ public void displayPreference_BluetoothOff_preferenceInvisible() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mPreference.setVisible(true);
+ mShadowBluetoothAdapter.setEnabled(false);
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isFalse();
+ }
+
+ @Test
+ public void displayPreference_BluetoothOnProfileNotReady_preferenceInvisible() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mPreference.setVisible(true);
+ when(mBroadcast.isProfileReady()).thenReturn(false);
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isFalse();
+ }
+
+ @Test
+ public void displayPreference_BluetoothOnProfileReady_preferenceVisible() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ mPreference.setVisible(false);
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isTrue();
+ }
+
+ @Test
+ public void onServiceConnected_updateVisibility() {
+ mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
+ when(mBroadcast.isProfileReady()).thenReturn(false);
+ mController.displayPreference(mScreen);
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isFalse();
+
+ when(mBroadcast.isProfileReady()).thenReturn(true);
+ mController.onServiceConnected();
+ shadowOf(Looper.getMainLooper()).idle();
+ assertThat(mPreference.isVisible()).isTrue();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceBadCodeStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceBadCodeStateTest.java
new file mode 100644
index 0000000..2fddff5
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceBadCodeStateTest.java
@@ -0,0 +1,58 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AddSourceBadCodeState.AUDIO_STREAM_ADD_SOURCE_BAD_CODE_STATE_SUMMARY;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class AddSourceBadCodeStateTest {
+ private AddSourceBadCodeState mInstance;
+
+ @Before
+ public void setUp() {
+ mInstance = AddSourceBadCodeState.getInstance();
+ }
+
+ @Test
+ public void testGetInstance() {
+ assertThat(mInstance).isNotNull();
+ assertThat(mInstance).isInstanceOf(SyncedState.class);
+ }
+
+ @Test
+ public void testGetSummary() {
+ int summary = mInstance.getSummary();
+ assertThat(summary).isEqualTo(AUDIO_STREAM_ADD_SOURCE_BAD_CODE_STATE_SUMMARY);
+ }
+
+ @Test
+ public void testGetStateEnum() {
+ AudioStreamsProgressCategoryController.AudioStreamState stateEnum =
+ mInstance.getStateEnum();
+ assertThat(stateEnum)
+ .isEqualTo(
+ AudioStreamsProgressCategoryController.AudioStreamState
+ .ADD_SOURCE_BAD_CODE);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceFailedStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceFailedStateTest.java
new file mode 100644
index 0000000..d8b1fcf
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceFailedStateTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AddSourceFailedState.AUDIO_STREAM_ADD_SOURCE_FAILED_STATE_SUMMARY;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class AddSourceFailedStateTest {
+ private AddSourceFailedState mInstance;
+
+ @Before
+ public void setUp() {
+ mInstance = AddSourceFailedState.getInstance();
+ }
+
+ @Test
+ public void testGetInstance() {
+ assertThat(mInstance).isNotNull();
+ assertThat(mInstance).isInstanceOf(SyncedState.class);
+ }
+
+ @Test
+ public void testGetSummary() {
+ int summary = mInstance.getSummary();
+ assertThat(summary).isEqualTo(AUDIO_STREAM_ADD_SOURCE_FAILED_STATE_SUMMARY);
+ }
+
+ @Test
+ public void testGetStateEnum() {
+ AudioStreamsProgressCategoryController.AudioStreamState stateEnum =
+ mInstance.getStateEnum();
+ assertThat(stateEnum)
+ .isEqualTo(
+ AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_FAILED);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseStateTest.java
new file mode 100644
index 0000000..00357b4
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AddSourceWaitForResponseStateTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AddSourceWaitForResponseState.ADD_SOURCE_WAIT_FOR_RESPONSE_TIMEOUT_MILLIS;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+
+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.shadows.ShadowLooper;
+
+import java.util.concurrent.TimeUnit;
+
+@RunWith(RobolectricTestRunner.class)
+public class AddSourceWaitForResponseStateTest {
+ private static final int BROADCAST_ID = 1;
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Mock private AudioStreamPreference mMockPreference;
+ @Mock private AudioStreamsProgressCategoryController mMockController;
+ @Mock private AudioStreamsHelper mMockHelper;
+ @Mock private BluetoothLeBroadcastMetadata mMockMetadata;
+ private AddSourceWaitForResponseState mInstance;
+
+ @Before
+ public void setUp() {
+ mInstance = AddSourceWaitForResponseState.getInstance();
+ }
+
+ @Test
+ public void testGetInstance() {
+ assertThat(mInstance).isNotNull();
+ assertThat(mInstance).isInstanceOf(AudioStreamStateHandler.class);
+ }
+
+ @Test
+ public void testGetSummary() {
+ int summary = mInstance.getSummary();
+ assertThat(summary)
+ .isEqualTo(
+ AddSourceWaitForResponseState
+ .AUDIO_STREAM_ADD_SOURCE_WAIT_FOR_RESPONSE_STATE_SUMMARY);
+ }
+
+ @Test
+ public void testGetStateEnum() {
+ AudioStreamsProgressCategoryController.AudioStreamState stateEnum =
+ mInstance.getStateEnum();
+ assertThat(stateEnum)
+ .isEqualTo(
+ AudioStreamsProgressCategoryController.AudioStreamState
+ .ADD_SOURCE_WAIT_FOR_RESPONSE);
+ }
+
+ @Test
+ public void testPerformAction_metadataIsNull_doNothing() {
+ when(mMockPreference.getAudioStreamMetadata()).thenReturn(null);
+
+ mInstance.performAction(mMockPreference, mMockController, mMockHelper);
+
+ verify(mMockHelper, never()).addSource(any());
+ }
+
+ @Test
+ public void testPerformAction_metadataIsNotNull_addSource() {
+ when(mMockPreference.getAudioStreamMetadata()).thenReturn(mMockMetadata);
+
+ mInstance.performAction(mMockPreference, mMockController, mMockHelper);
+
+ verify(mMockHelper).addSource(mMockMetadata);
+ verify(mMockController, never()).handleSourceFailedToConnect(anyInt());
+ }
+
+ @Test
+ public void testPerformAction_timeout_addSource_sourceFailedToConnect() {
+ when(mMockPreference.getAudioStreamMetadata()).thenReturn(mMockMetadata);
+ when(mMockPreference.isShown()).thenReturn(true);
+ when(mMockPreference.getAudioStreamState()).thenReturn(mInstance.getStateEnum());
+ when(mMockPreference.getAudioStreamBroadcastId()).thenReturn(BROADCAST_ID);
+
+ mInstance.performAction(mMockPreference, mMockController, mMockHelper);
+ ShadowLooper.idleMainLooper(ADD_SOURCE_WAIT_FOR_RESPONSE_TIMEOUT_MILLIS, TimeUnit.SECONDS);
+
+ verify(mMockHelper).addSource(mMockMetadata);
+ verify(mMockController).handleSourceFailedToConnect(BROADCAST_ID);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonControllerTest.java
new file mode 100644
index 0000000..adcc617
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamButtonControllerTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.content.Context;
+import android.view.View;
+
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.R;
+import com.android.settings.connecteddevice.audiosharing.audiostreams.testshadows.ShadowAudioStreamsHelper;
+import com.android.settings.testutils.shadow.ShadowThreadUtils;
+import com.android.settingslib.widget.ActionButtonsPreference;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.Collections;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowThreadUtils.class,
+ ShadowAudioStreamsHelper.class,
+ })
+public class AudioStreamButtonControllerTest {
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ private static final String KEY = "audio_stream_button";
+ private static final int BROADCAST_ID = 1;
+ @Spy Context mContext = ApplicationProvider.getApplicationContext();
+ @Mock private AudioStreamsHelper mAudioStreamsHelper;
+ @Mock private PreferenceScreen mScreen;
+ @Mock private BluetoothLeBroadcastReceiveState mBroadcastReceiveState;
+ @Mock private ActionButtonsPreference mPreference;
+ private AudioStreamButtonController mController;
+
+ @Before
+ public void setUp() {
+ ShadowAudioStreamsHelper.setUseMock(mAudioStreamsHelper);
+ mController = new AudioStreamButtonController(mContext, KEY);
+ mController.init(BROADCAST_ID);
+ when(mScreen.findPreference(KEY)).thenReturn(mPreference);
+ when(mPreference.getContext()).thenReturn(mContext);
+ when(mPreference.setButton1Text(anyInt())).thenReturn(mPreference);
+ when(mPreference.setButton1Icon(anyInt())).thenReturn(mPreference);
+ when(mPreference.setButton1Enabled(anyBoolean())).thenReturn(mPreference);
+ when(mPreference.setButton1OnClickListener(any(View.OnClickListener.class)))
+ .thenReturn(mPreference);
+ }
+
+ @Test
+ public void testDisplayPreference_sourceConnected_setDisconnectButton() {
+ when(mAudioStreamsHelper.getAllConnectedSources())
+ .thenReturn(List.of(mBroadcastReceiveState));
+ when(mBroadcastReceiveState.getBroadcastId()).thenReturn(BROADCAST_ID);
+
+ mController.displayPreference(mScreen);
+
+ verify(mPreference).setButton1Enabled(true);
+ verify(mPreference).setButton1Text(R.string.audio_streams_disconnect);
+ verify(mPreference).setButton1Icon(com.android.settings.R.drawable.ic_settings_close);
+ verify(mPreference).setButton1OnClickListener(any(View.OnClickListener.class));
+ }
+
+ @Test
+ public void testDisplayPreference_sourceNotConnected_setConnectButton() {
+ when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(Collections.emptyList());
+
+ mController.displayPreference(mScreen);
+
+ verify(mPreference).setButton1Enabled(true);
+ verify(mPreference).setButton1Text(R.string.audio_streams_connect);
+ verify(mPreference).setButton1Icon(com.android.settings.R.drawable.ic_add_24dp);
+ verify(mPreference).setButton1OnClickListener(any(View.OnClickListener.class));
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderControllerTest.java
new file mode 100644
index 0000000..0af9c17
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamHeaderControllerTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamHeaderController.AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY;
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamHeaderController.AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+import android.content.Context;
+
+import androidx.preference.PreferenceScreen;
+import androidx.test.core.app.ApplicationProvider;
+
+import com.android.settings.connecteddevice.audiosharing.audiostreams.testshadows.ShadowAudioStreamsHelper;
+import com.android.settings.connecteddevice.audiosharing.audiostreams.testshadows.ShadowEntityHeaderController;
+import com.android.settings.testutils.shadow.ShadowThreadUtils;
+import com.android.settings.widget.EntityHeaderController;
+import com.android.settingslib.widget.LayoutPreference;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.Collections;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowEntityHeaderController.class,
+ ShadowThreadUtils.class,
+ ShadowAudioStreamsHelper.class,
+ })
+public class AudioStreamHeaderControllerTest {
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ private static final String KEY = "audio_stream_header";
+ private static final int BROADCAST_ID = 1;
+ private static final String BROADCAST_NAME = "broadcast name";
+ @Spy Context mContext = ApplicationProvider.getApplicationContext();
+ @Mock private AudioStreamsHelper mAudioStreamsHelper;
+ @Mock private PreferenceScreen mScreen;
+ @Mock private BluetoothLeBroadcastReceiveState mBroadcastReceiveState;
+ @Mock private AudioStreamDetailsFragment mFragment;
+ @Mock private LayoutPreference mPreference;
+ @Mock private EntityHeaderController mHeaderController;
+ private AudioStreamHeaderController mController;
+
+ @Before
+ public void setUp() {
+ ShadowEntityHeaderController.setUseMock(mHeaderController);
+ ShadowAudioStreamsHelper.setUseMock(mAudioStreamsHelper);
+ mController = new AudioStreamHeaderController(mContext, KEY);
+ mController.init(mFragment, BROADCAST_NAME, BROADCAST_ID);
+ when(mScreen.findPreference(KEY)).thenReturn(mPreference);
+ when(mScreen.getContext()).thenReturn(mContext);
+ when(mPreference.getContext()).thenReturn(mContext);
+ }
+
+ @Test
+ public void testDisplayPreference_sourceConnected_setSummary() {
+ when(mAudioStreamsHelper.getAllConnectedSources())
+ .thenReturn(List.of(mBroadcastReceiveState));
+ when(mBroadcastReceiveState.getBroadcastId()).thenReturn(BROADCAST_ID);
+
+ mController.displayPreference(mScreen);
+
+ verify(mHeaderController).setLabel(BROADCAST_NAME);
+ verify(mHeaderController)
+ .setSummary(mContext.getString(AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY));
+ verify(mHeaderController).done(true);
+ }
+
+ @Test
+ public void testDisplayPreference_sourceNotConnected_setSummary() {
+ when(mAudioStreamsHelper.getAllConnectedSources()).thenReturn(Collections.emptyList());
+
+ mController.displayPreference(mScreen);
+
+ verify(mHeaderController).setLabel(BROADCAST_NAME);
+ verify(mHeaderController).setSummary(AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY);
+ verify(mHeaderController).done(true);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/MediaControlHelperTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/MediaControlHelperTest.java
new file mode 100644
index 0000000..113fc72
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/MediaControlHelperTest.java
@@ -0,0 +1,134 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import static org.mockito.Mockito.*;
+
+import android.content.Context;
+import android.media.session.MediaController;
+import android.media.session.MediaSessionManager;
+import android.media.session.PlaybackState;
+
+import com.android.settings.connecteddevice.audiosharing.audiostreams.testshadows.ShadowAudioStreamsHelper;
+import com.android.settings.connecteddevice.audiosharing.audiostreams.testshadows.ShadowLocalMediaManager;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.media.BluetoothMediaDevice;
+import com.android.settingslib.media.LocalMediaManager;
+
+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.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowAudioStreamsHelper.class,
+ ShadowLocalMediaManager.class,
+ })
+public class MediaControlHelperTest {
+ private static final String FAKE_PACKAGE = "fake_package";
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ private Context mContext;
+ @Mock private LocalBluetoothManager mLocalBluetoothManager;
+ @Mock private MediaSessionManager mMediaSessionManager;
+ @Mock private MediaController mMediaController;
+ @Mock private CachedBluetoothDevice mCachedBluetoothDevice;
+ @Mock private PlaybackState mPlaybackState;
+ @Mock private LocalMediaManager mLocalMediaManager;
+ @Mock private BluetoothMediaDevice mBluetoothMediaDevice;
+
+ @Before
+ public void setUp() {
+ mContext = spy(RuntimeEnvironment.application);
+ when(mContext.getSystemService(MediaSessionManager.class)).thenReturn(mMediaSessionManager);
+ when(mMediaSessionManager.getActiveSessions(any())).thenReturn(List.of(mMediaController));
+ when(mMediaController.getPackageName()).thenReturn(FAKE_PACKAGE);
+ when(mMediaController.getPlaybackState()).thenReturn(mPlaybackState);
+ ShadowAudioStreamsHelper.resetCachedBluetoothDevice();
+ ShadowLocalMediaManager.setUseMock(mLocalMediaManager);
+ }
+
+ @Test
+ public void testStart_noBluetoothManager_doNothing() {
+ MediaControlHelper helper = new MediaControlHelper(mContext, null);
+ helper.start();
+
+ verify(mLocalMediaManager, never()).startScan();
+ }
+
+ @Test
+ public void testStart_noConnectedDevice_doNothing() {
+ MediaControlHelper helper = new MediaControlHelper(mContext, mLocalBluetoothManager);
+ helper.start();
+
+ verify(mLocalMediaManager, never()).startScan();
+ }
+
+ @Test
+ public void testStart_isStopped_onDeviceListUpdate_shouldNotStopMedia() {
+ ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(
+ mCachedBluetoothDevice);
+ when(mPlaybackState.getState()).thenReturn(PlaybackState.STATE_STOPPED);
+ when(mLocalMediaManager.getCurrentConnectedDevice()).thenReturn(null);
+
+ MediaControlHelper helper = new MediaControlHelper(mContext, mLocalBluetoothManager);
+ helper.start();
+ ShadowLocalMediaManager.onDeviceListUpdate();
+
+ verify(mMediaController, never()).getTransportControls();
+ }
+
+ @Test
+ public void testStart_isPlaying_onDeviceListUpdate_noDevice_shouldNotStopMedia() {
+ ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(
+ mCachedBluetoothDevice);
+ when(mPlaybackState.getState()).thenReturn(PlaybackState.STATE_PLAYING);
+ when(mLocalMediaManager.getCurrentConnectedDevice()).thenReturn(null);
+
+ MediaControlHelper helper = new MediaControlHelper(mContext, mLocalBluetoothManager);
+ helper.start();
+ ShadowLocalMediaManager.onDeviceListUpdate();
+
+ verify(mMediaController, never()).getTransportControls();
+ }
+
+ @Test
+ public void testStart_isPlaying_onDeviceListUpdate_deviceMatch_shouldStopMedia() {
+ ShadowAudioStreamsHelper.setCachedBluetoothDeviceInSharingOrLeConnected(
+ mCachedBluetoothDevice);
+ when(mPlaybackState.getState()).thenReturn(PlaybackState.STATE_PLAYING);
+ when(mLocalMediaManager.getCurrentConnectedDevice()).thenReturn(mBluetoothMediaDevice);
+ when(mBluetoothMediaDevice.getCachedDevice()).thenReturn(mCachedBluetoothDevice);
+
+ MediaControlHelper helper = new MediaControlHelper(mContext, mLocalBluetoothManager);
+ helper.start();
+ ShadowLocalMediaManager.onDeviceListUpdate();
+
+ verify(mMediaController).getTransportControls();
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedStateTest.java
new file mode 100644
index 0000000..0f0bafe
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SourceAddedStateTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.SourceAddedState.AUDIO_STREAM_SOURCE_ADDED_STATE_SUMMARY;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class SourceAddedStateTest {
+
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ private SourceAddedState mInstance;
+
+ @Before
+ public void setUp() {
+ mInstance = SourceAddedState.getInstance();
+ }
+
+ @Test
+ public void testGetInstance() {
+ assertThat(mInstance).isNotNull();
+ assertThat(mInstance).isInstanceOf(SourceAddedState.class);
+ }
+
+ @Test
+ public void testGetSummary() {
+ int summary = mInstance.getSummary();
+ assertThat(summary).isEqualTo(AUDIO_STREAM_SOURCE_ADDED_STATE_SUMMARY);
+ }
+
+ @Test
+ public void testGetStateEnum() {
+ AudioStreamsProgressCategoryController.AudioStreamState stateEnum =
+ mInstance.getStateEnum();
+ assertThat(stateEnum)
+ .isEqualTo(AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_ADDED);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SyncedStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SyncedStateTest.java
new file mode 100644
index 0000000..64e1bc4
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/SyncedStateTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamStateHandler.EMPTY_STRING_RES;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
+
+import android.app.AlertDialog;
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+import android.content.Context;
+
+import androidx.preference.Preference;
+import androidx.test.core.app.ApplicationProvider;
+
+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 org.robolectric.shadows.ShadowAlertDialog;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(
+ shadows = {
+ ShadowAlertDialog.class,
+ })
+public class SyncedStateTest {
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Mock private AudioStreamsProgressCategoryController mMockController;
+ @Mock private AudioStreamPreference mMockPreference;
+ @Mock private BluetoothLeBroadcastMetadata mMockMetadata;
+ private Context mMockContext;
+ private SyncedState mInstance;
+
+ @Before
+ public void setUp() {
+ ShadowAlertDialog.reset();
+ mMockContext = spy(ApplicationProvider.getApplicationContext());
+ mInstance = SyncedState.getInstance();
+ }
+
+ @Test
+ public void testGetInstance() {
+ assertThat(mInstance).isNotNull();
+ assertThat(mInstance).isInstanceOf(AudioStreamStateHandler.class);
+ }
+
+ @Test
+ public void testGetSummary() {
+ int summary = mInstance.getSummary();
+ assertThat(summary).isEqualTo(EMPTY_STRING_RES);
+ }
+
+ @Test
+ public void testGetStateEnum() {
+ AudioStreamsProgressCategoryController.AudioStreamState stateEnum =
+ mInstance.getStateEnum();
+ assertThat(stateEnum)
+ .isEqualTo(AudioStreamsProgressCategoryController.AudioStreamState.SYNCED);
+ }
+
+ @Test
+ public void testGetOnClickListener_isNotEncrypted_handleSourceAddRequest() {
+ Preference.OnPreferenceClickListener listener =
+ mInstance.getOnClickListener(mMockController);
+ when(mMockPreference.getAudioStreamMetadata()).thenReturn(mMockMetadata);
+
+ listener.onPreferenceClick(mMockPreference);
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialog.getLatestAlertDialog();
+ assertThat(dialog).isNull();
+ verify(mMockController).handleSourceAddRequest(mMockPreference, mMockMetadata);
+ }
+
+ @Test
+ public void testGetOnClickListener_isEncrypted_passwordDialogShowing() {
+ Preference.OnPreferenceClickListener listener =
+ mInstance.getOnClickListener(mMockController);
+ when(mMockPreference.getAudioStreamMetadata()).thenReturn(mMockMetadata);
+ when(mMockPreference.getContext()).thenReturn(mMockContext);
+ when(mMockMetadata.isEncrypted()).thenReturn(true);
+
+ listener.onPreferenceClick(mMockPreference);
+ shadowMainLooper().idle();
+
+ AlertDialog dialog = ShadowAlertDialog.getLatestAlertDialog();
+ assertThat(dialog).isNotNull();
+ assertThat(dialog.isShowing()).isTrue();
+ verify(mMockController, never()).handleSourceAddRequest(mMockPreference, mMockMetadata);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncStateTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncStateTest.java
new file mode 100644
index 0000000..5297182
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/WaitForSyncStateTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams;
+
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.WaitForSyncState.AUDIO_STREAM_WAIT_FOR_SYNC_STATE_SUMMARY;
+import static com.android.settings.connecteddevice.audiosharing.audiostreams.WaitForSyncState.WAIT_FOR_SYNC_TIMEOUT_MILLIS;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.bluetooth.BluetoothLeBroadcastMetadata;
+
+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.shadows.ShadowLooper;
+
+import java.util.concurrent.TimeUnit;
+
+@RunWith(RobolectricTestRunner.class)
+public class WaitForSyncStateTest {
+ @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Mock private AudioStreamPreference mMockPreference;
+ @Mock private AudioStreamsProgressCategoryController mMockController;
+ @Mock private AudioStreamsHelper mMockHelper;
+ @Mock private BluetoothLeBroadcastMetadata mMockMetadata;
+ private WaitForSyncState mInstance;
+
+ @Before
+ public void setUp() {
+ mInstance = WaitForSyncState.getInstance();
+ }
+
+ @Test
+ public void testGetInstance() {
+ assertThat(mInstance).isNotNull();
+ assertThat(mInstance).isInstanceOf(AudioStreamStateHandler.class);
+ }
+
+ @Test
+ public void testGetSummary() {
+ int summary = mInstance.getSummary();
+ assertThat(summary).isEqualTo(AUDIO_STREAM_WAIT_FOR_SYNC_STATE_SUMMARY);
+ }
+
+ @Test
+ public void testGetStateEnum() {
+ AudioStreamsProgressCategoryController.AudioStreamState stateEnum =
+ mInstance.getStateEnum();
+ assertThat(stateEnum)
+ .isEqualTo(AudioStreamsProgressCategoryController.AudioStreamState.WAIT_FOR_SYNC);
+ }
+
+ @Test
+ public void testPerformAction_timeout_stateNotMatching_doNothing() {
+ when(mMockPreference.isShown()).thenReturn(true);
+ when(mMockPreference.getAudioStreamState())
+ .thenReturn(AudioStreamsProgressCategoryController.AudioStreamState.UNKNOWN);
+
+ mInstance.performAction(mMockPreference, mMockController, mMockHelper);
+ ShadowLooper.idleMainLooper(WAIT_FOR_SYNC_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+
+ verify(mMockController, never()).handleSourceLost(anyInt());
+ }
+
+ @Test
+ public void testPerformAction_timeout_stateMatching_sourceLost() {
+ when(mMockPreference.isShown()).thenReturn(true);
+ when(mMockPreference.getAudioStreamState())
+ .thenReturn(AudioStreamsProgressCategoryController.AudioStreamState.WAIT_FOR_SYNC);
+ when(mMockPreference.getAudioStreamBroadcastId()).thenReturn(1);
+ when(mMockPreference.getAudioStreamMetadata()).thenReturn(mMockMetadata);
+
+ mInstance.performAction(mMockPreference, mMockController, mMockHelper);
+ ShadowLooper.idleMainLooper(WAIT_FOR_SYNC_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
+
+ verify(mMockController).handleSourceLost(1);
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowAudioStreamsHelper.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowAudioStreamsHelper.java
new file mode 100644
index 0000000..0dff64d
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowAudioStreamsHelper.java
@@ -0,0 +1,61 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams.testshadows;
+
+import android.bluetooth.BluetoothLeBroadcastReceiveState;
+
+import com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsHelper;
+import com.android.settingslib.bluetooth.CachedBluetoothDevice;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+import java.util.List;
+import java.util.Optional;
+
+@Implements(value = AudioStreamsHelper.class, callThroughByDefault = false)
+public class ShadowAudioStreamsHelper {
+ private static AudioStreamsHelper sMockHelper;
+ private static Optional<CachedBluetoothDevice> sCachedBluetoothDevice;
+
+ public static void setUseMock(AudioStreamsHelper mockAudioStreamsHelper) {
+ sMockHelper = mockAudioStreamsHelper;
+ }
+
+ /** Resets {@link CachedBluetoothDevice} */
+ public static void resetCachedBluetoothDevice() {
+ sCachedBluetoothDevice = Optional.empty();
+ }
+
+ public static void setCachedBluetoothDeviceInSharingOrLeConnected(
+ CachedBluetoothDevice cachedBluetoothDevice) {
+ sCachedBluetoothDevice = Optional.of(cachedBluetoothDevice);
+ }
+
+ @Implementation
+ public List<BluetoothLeBroadcastReceiveState> getAllConnectedSources() {
+ return sMockHelper.getAllConnectedSources();
+ }
+
+ /** Gets {@link CachedBluetoothDevice} in sharing or le connected */
+ @Implementation
+ public static Optional<CachedBluetoothDevice> getCachedBluetoothDeviceInSharingOrLeConnected(
+ LocalBluetoothManager manager) {
+ return sCachedBluetoothDevice;
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowEntityHeaderController.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowEntityHeaderController.java
new file mode 100644
index 0000000..951fb26
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowEntityHeaderController.java
@@ -0,0 +1,43 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams.testshadows;
+
+import android.app.Activity;
+import android.view.View;
+
+import androidx.fragment.app.Fragment;
+
+import com.android.settings.widget.EntityHeaderController;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+@Implements(value = EntityHeaderController.class, callThroughByDefault = false)
+public class ShadowEntityHeaderController {
+ private static EntityHeaderController sMockController;
+
+ public static void setUseMock(EntityHeaderController mockController) {
+ sMockController = mockController;
+ }
+
+ /** Returns new instance of {@link EntityHeaderController} */
+ @Implementation
+ public static EntityHeaderController newInstance(
+ Activity activity, Fragment fragment, View header) {
+ return sMockController;
+ }
+}
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowLocalMediaManager.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowLocalMediaManager.java
new file mode 100644
index 0000000..02f12c2
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/testshadows/ShadowLocalMediaManager.java
@@ -0,0 +1,59 @@
+/*
+ * 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.settings.connecteddevice.audiosharing.audiostreams.testshadows;
+
+import com.android.settingslib.media.LocalMediaManager;
+import com.android.settingslib.media.MediaDevice;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+import java.util.Collections;
+
+@Implements(value = LocalMediaManager.class, callThroughByDefault = false)
+public class ShadowLocalMediaManager {
+
+ private static LocalMediaManager sMockManager;
+ private static LocalMediaManager.DeviceCallback sDeviceCallback;
+
+ public static void setUseMock(LocalMediaManager mockLocalMediaManager) {
+ sMockManager = mockLocalMediaManager;
+ }
+
+ /** Triggers onDeviceListUpdate of {@link LocalMediaManager.DeviceCallback} */
+ public static void onDeviceListUpdate() {
+ sDeviceCallback.onDeviceListUpdate(Collections.emptyList());
+ }
+
+ /** Starts scan */
+ @Implementation
+ public void startScan() {
+ sMockManager.startScan();
+ }
+
+ /** Registers {@link LocalMediaManager.DeviceCallback} */
+ @Implementation
+ public void registerCallback(LocalMediaManager.DeviceCallback deviceCallback) {
+ sMockManager.registerCallback(deviceCallback);
+ sDeviceCallback = deviceCallback;
+ }
+
+ @Implementation
+ public MediaDevice getCurrentConnectedDevice() {
+ return sMockManager.getCurrentConnectedDevice();
+ }
+}
diff --git a/tests/robotests/testutils/com/android/settings/testutils/FakeFeatureFactory.java b/tests/robotests/testutils/com/android/settings/testutils/FakeFeatureFactory.java
index 71f8e58..e0f4b9e 100644
--- a/tests/robotests/testutils/com/android/settings/testutils/FakeFeatureFactory.java
+++ b/tests/robotests/testutils/com/android/settings/testutils/FakeFeatureFactory.java
@@ -27,7 +27,6 @@
import com.android.settings.biometrics.fingerprint.FingerprintFeatureProvider;
import com.android.settings.biometrics2.factory.BiometricsRepositoryProvider;
import com.android.settings.bluetooth.BluetoothFeatureProvider;
-import com.android.settings.connecteddevice.audiosharing.AudioSharingFeatureProvider;
import com.android.settings.connecteddevice.fastpair.FastPairFeatureProvider;
import com.android.settings.connecteddevice.stylus.StylusFeatureProvider;
import com.android.settings.dashboard.DashboardFeatureProvider;
@@ -103,7 +102,6 @@
public FastPairFeatureProvider mFastPairFeatureProvider;
public PrivateSpaceLoginFeatureProvider mPrivateSpaceLoginFeatureProvider;
public DisplayFeatureProvider mDisplayFeatureProvider;
- public AudioSharingFeatureProvider mAudioSharingFeatureProvider;
public SyncAcrossDevicesFeatureProvider mSyncAcrossDevicesFeatureProvider;
/**
@@ -154,7 +152,6 @@
mFastPairFeatureProvider = mock(FastPairFeatureProvider.class);
mPrivateSpaceLoginFeatureProvider = mock(PrivateSpaceLoginFeatureProvider.class);
mDisplayFeatureProvider = mock(DisplayFeatureProvider.class);
- mAudioSharingFeatureProvider = mock(AudioSharingFeatureProvider.class);
mSyncAcrossDevicesFeatureProvider = mock(SyncAcrossDevicesFeatureProvider.class);
}
@@ -340,11 +337,6 @@
}
@Override
- public AudioSharingFeatureProvider getAudioSharingFeatureProvider() {
- return mAudioSharingFeatureProvider;
- }
-
- @Override
public SyncAcrossDevicesFeatureProvider getSyncAcrossDevicesFeatureProvider() {
return mSyncAcrossDevicesFeatureProvider;
}
diff --git a/tests/spa_unit/src/com/android/settings/testutils/FakeFeatureFactory.kt b/tests/spa_unit/src/com/android/settings/testutils/FakeFeatureFactory.kt
index e1dcda2..5ca24a3 100644
--- a/tests/spa_unit/src/com/android/settings/testutils/FakeFeatureFactory.kt
+++ b/tests/spa_unit/src/com/android/settings/testutils/FakeFeatureFactory.kt
@@ -25,7 +25,6 @@
import com.android.settings.biometrics.fingerprint.FingerprintFeatureProvider
import com.android.settings.biometrics2.factory.BiometricsRepositoryProvider
import com.android.settings.bluetooth.BluetoothFeatureProvider
-import com.android.settings.connecteddevice.audiosharing.AudioSharingFeatureProvider
import com.android.settings.connecteddevice.fastpair.FastPairFeatureProvider
import com.android.settings.connecteddevice.stylus.StylusFeatureProvider
import com.android.settings.dashboard.DashboardFeatureProvider
@@ -151,8 +150,6 @@
get() = TODO("Not yet implemented")
override val displayFeatureProvider: DisplayFeatureProvider
get() = TODO("Not yet implemented")
- override val audioSharingFeatureProvider: AudioSharingFeatureProvider
- get() = TODO("Not yet implemented")
override val syncAcrossDevicesFeatureProvider: SyncAcrossDevicesFeatureProvider
get() = TODO("Not yet implemented")
}
diff --git a/tests/unit/src/com/android/settings/testutils/FakeFeatureFactory.java b/tests/unit/src/com/android/settings/testutils/FakeFeatureFactory.java
index cc129fd..b8dd5ac 100644
--- a/tests/unit/src/com/android/settings/testutils/FakeFeatureFactory.java
+++ b/tests/unit/src/com/android/settings/testutils/FakeFeatureFactory.java
@@ -27,7 +27,6 @@
import com.android.settings.biometrics.fingerprint.FingerprintFeatureProvider;
import com.android.settings.biometrics2.factory.BiometricsRepositoryProvider;
import com.android.settings.bluetooth.BluetoothFeatureProvider;
-import com.android.settings.connecteddevice.audiosharing.AudioSharingFeatureProvider;
import com.android.settings.connecteddevice.fastpair.FastPairFeatureProvider;
import com.android.settings.connecteddevice.stylus.StylusFeatureProvider;
import com.android.settings.dashboard.DashboardFeatureProvider;
@@ -102,7 +101,6 @@
public FastPairFeatureProvider mFastPairFeatureProvider;
public PrivateSpaceLoginFeatureProvider mPrivateSpaceLoginFeatureProvider;
public DisplayFeatureProvider mDisplayFeatureProvider;
- public AudioSharingFeatureProvider mAudioSharingFeatureProvider;
public SyncAcrossDevicesFeatureProvider mSyncAcrossDevicesFeatureProvider;
/** Call this in {@code @Before} method of the test class to use fake factory. */
@@ -155,7 +153,6 @@
mFastPairFeatureProvider = mock(FastPairFeatureProvider.class);
mPrivateSpaceLoginFeatureProvider = mock(PrivateSpaceLoginFeatureProvider.class);
mDisplayFeatureProvider = mock(DisplayFeatureProvider.class);
- mAudioSharingFeatureProvider = mock(AudioSharingFeatureProvider.class);
mSyncAcrossDevicesFeatureProvider = mock(SyncAcrossDevicesFeatureProvider.class);
}
@@ -341,11 +338,6 @@
}
@Override
- public AudioSharingFeatureProvider getAudioSharingFeatureProvider() {
- return mAudioSharingFeatureProvider;
- }
-
- @Override
public SyncAcrossDevicesFeatureProvider getSyncAcrossDevicesFeatureProvider() {
return mSyncAcrossDevicesFeatureProvider;
}