Merge "[Audiosharing] Migrate feature from overlay to Settings" into main

This commit is contained in:
Yiyi Shen
2024-05-17 09:30:14 +00:00
committed by Android (Google) Code Review
133 changed files with 18497 additions and 275 deletions

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 &amp; time</string>
@@ -7317,8 +7314,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>
@@ -13272,4 +13267,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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -26,9 +26,23 @@
settings:allowDividerBelow="true"
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"

View File

@@ -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"

View File

@@ -23,7 +23,8 @@ import android.util.Log;
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 @@ public class AvailableMediaBluetoothDeviceUpdater extends BluetoothDeviceUpdater
// 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 @@ public class AvailableMediaBluetoothDeviceUpdater extends BluetoothDeviceUpdater
+ ", 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;
}
}

View File

@@ -17,6 +17,10 @@ package com.android.settings.connecteddevice;
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.AvailableMediaBluetoothDeviceUpdater;
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 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
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 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
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 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
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 class AvailableMediaDeviceGroupController extends BasePreferenceControlle
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 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
fragment.getContext(),
AvailableMediaDeviceGroupController.this,
fragment.getMetricsCategory());
if (AudioSharingUtils.isFeatureEnabled()) {
mDialogHandler = new AudioSharingDialogHandler(mContext, fragment);
}
}
@VisibleForTesting
@@ -177,6 +278,11 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
mBluetoothDeviceUpdater = bluetoothDeviceUpdater;
}
@VisibleForTesting
public void setDialogHandler(AudioSharingDialogHandler dialogHandler) {
mDialogHandler = dialogHandler;
}
@Override
public void onAudioModeChanged() {
updateTitle();

View File

@@ -22,12 +22,13 @@ import android.provider.DeviceConfig;
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.overlay.SurveyFeatureProvider;
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 @@ public class ConnectedDeviceDashboardFragment extends DashboardFragment {
+ ", 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 @@ public class ConnectedDeviceDashboardFragment extends DashboardFragment {
}
}
@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 @@ public class ConnectedDeviceDashboardFragment extends DashboardFragment {
/** 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);
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
});
});
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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];
}
};
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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()));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
});
});
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
});
}
});
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
});
});
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
});
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
});
});
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
});
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -24,7 +24,6 @@ import com.android.settings.biometrics.face.FaceFeatureProvider
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
@@ -184,11 +183,6 @@ abstract class FeatureFactory {
*/
abstract val displayFeatureProvider: DisplayFeatureProvider
/**
* Gets implementation for audio sharing related feature.
*/
abstract val audioSharingFeatureProvider: AudioSharingFeatureProvider
/**
* Gets implementation for sync across devices related feature.
*/

View File

@@ -34,8 +34,6 @@ import com.android.settings.biometrics.fingerprint.FingerprintFeatureProviderImp
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 @@ open class FeatureFactoryImpl : FeatureFactory() {
DisplayFeatureProviderImpl()
}
override val audioSharingFeatureProvider: AudioSharingFeatureProvider by lazy {
AudioSharingFeatureProviderImpl()
}
override val syncAcrossDevicesFeatureProvider: SyncAcrossDevicesFeatureProvider by lazy {
SyncAcrossDevicesFeatureProviderImpl()
}

View File

@@ -24,26 +24,32 @@ import static org.mockito.Mockito.when;
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 org.robolectric.shadow.api.Shadow;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
@RunWith(RobolectricTestRunner.class)
@Config(
@@ -66,6 +73,8 @@ import java.util.Collection;
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 @@ public class AvailableMediaBluetoothDeviceUpdaterTest {
@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 @@ public class AvailableMediaBluetoothDeviceUpdaterTest {
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 @@ public class AvailableMediaBluetoothDeviceUpdaterTest {
@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 @@ public class AvailableMediaBluetoothDeviceUpdaterTest {
@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 @@ public class AvailableMediaBluetoothDeviceUpdaterTest {
@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 @@ public class AvailableMediaBluetoothDeviceUpdaterTest {
@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,

View File

@@ -22,18 +22,24 @@ import static com.google.common.truth.Truth.assertThat;
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.R;
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.Robolectric;
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 @@ public class AvailableMediaDeviceGroupControllerTest {
@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 @@ public class AvailableMediaDeviceGroupControllerTest {
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 @@ public class AvailableMediaDeviceGroupControllerTest {
}
@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 @@ public class AvailableMediaDeviceGroupControllerTest {
}
@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 @@ public class AvailableMediaDeviceGroupControllerTest {
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));
}
}

View File

@@ -72,6 +72,9 @@ public class ConnectedDeviceDashboardFragmentTest {
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 @@ public class ConnectedDeviceDashboardFragmentTest {
private Context mContext;
private ConnectedDeviceDashboardFragment mFragment;
private FakeFeatureFactory mFeatureFactory;
private AvailableMediaDeviceGroupController mMediaDeviceGroupController;
@Before
public void setUp() {
@@ -93,21 +95,13 @@ public class ConnectedDeviceDashboardFragmentTest {
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 @@ public class ConnectedDeviceDashboardFragmentTest {
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

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More