[Audiosharing] Migrate feature from overlay to Settings
Bug: 340379827 Test: atest Change-Id: I3a88ac1d2f575f3be1f26f617479bbfd25cf6a8e
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
res/drawable/audio_sharing_guidance.png
Normal file
BIN
res/drawable/audio_sharing_guidance.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
22
res/drawable/audio_sharing_rounded_bg.xml
Normal file
22
res/drawable/audio_sharing_rounded_bg.xml
Normal 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>
|
||||
21
res/drawable/audio_sharing_rounded_bg_ripple.xml
Normal file
21
res/drawable/audio_sharing_rounded_bg_ripple.xml
Normal 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>
|
||||
32
res/drawable/ic_audio_calls_and_alarms.xml
Normal file
32
res/drawable/ic_audio_calls_and_alarms.xml
Normal 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>
|
||||
32
res/drawable/ic_audio_play_sample.xml
Normal file
32
res/drawable/ic_audio_play_sample.xml
Normal 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>
|
||||
33
res/layout/audio_sharing_device_item.xml
Normal file
33
res/layout/audio_sharing_device_item.xml
Normal 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>
|
||||
75
res/layout/audio_sharing_password_dialog.xml
Normal file
75
res/layout/audio_sharing_password_dialog.xml
Normal 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>
|
||||
71
res/layout/dialog_custom_body_audio_sharing.xml
Normal file
71
res/layout/dialog_custom_body_audio_sharing.xml
Normal 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>
|
||||
42
res/layout/dialog_custom_title_audio_sharing.xml
Normal file
42
res/layout/dialog_custom_title_audio_sharing.xml
Normal 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>
|
||||
29
res/layout/preference_widget_lock.xml
Normal file
29
res/layout/preference_widget_lock.xml
Normal 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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -303,9 +303,6 @@
|
||||
<!-- Name shown in the title of individual stylus preference in the connected devices page [CHAR LIMIT=60] -->
|
||||
<string name="stylus_connected_devices_title">Stylus</string>
|
||||
|
||||
<!--Text that appears when scanning for nearby audio streams is finished and no streams were found [CHAR LIMIT=40]-->
|
||||
<string name="audio_streams_empty">No nearby audio streams were found.</string>
|
||||
|
||||
<!-- Date & time settings screen title -->
|
||||
<string name="date_and_time">Date & time</string>
|
||||
|
||||
@@ -7315,8 +7312,6 @@
|
||||
<string name="help_url_insecure_vpn" translatable="false"></string>
|
||||
<!-- url for learning more about IT admin policy disabling -->
|
||||
<string name="help_url_action_disabled_by_it_admin" translatable="false"></string>
|
||||
<!-- url for learning more about bluetooth audio sharing -->
|
||||
<string name="help_url_audio_sharing" translatable="false"></string>
|
||||
|
||||
<!-- User account title [CHAR LIMIT=30] -->
|
||||
<string name="user_account_title">Account for content</string>
|
||||
@@ -13270,4 +13265,198 @@
|
||||
|
||||
<!-- Title for System dashboard fragment -->
|
||||
<string name="device_diagnostics_title">Device diagnostics</string>
|
||||
|
||||
<!-- Title for audio sharing page [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_title">Audio sharing</string>
|
||||
<!-- Title for audio sharing primary switch [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_switch_title">Share audio</string>
|
||||
<!-- Title for calls and alarms device on audio sharing page [CHAR LIMIT=none]-->
|
||||
<string name="calls_and_alarms_device_title">Calls and alarms</string>
|
||||
<!-- Description for audio sharing page [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_description">Let people listen to your media along with you. Listeners need their own LE Audio headphones.</string>
|
||||
<!-- Title for audio sharing device group [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_device_group_title">Active media devices</string>
|
||||
<!-- Title for call audio on audio sharing page [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_call_audio_title">Call audio</string>
|
||||
<!-- Description for call audio on audio sharing page [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_call_audio_description">Play only on <xliff:g example="My buds" id="device_name">%1$s</xliff:g></string>
|
||||
<!-- Title for play test sound on audio sharing page [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_test_sound_title">Play a test sound</string>
|
||||
<!-- Description for play test sound on audio sharing page [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_test_sound_description">Everyone listening should hear it</string>
|
||||
<!-- Title for stream settings group on audio sharing page [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stream_settings_title">Audio stream settings</string>
|
||||
<!-- Title for stream name on audio sharing page, under stream settings group [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stream_name_title">Name</string>
|
||||
<!-- Title for stream password on audio sharing page, under stream settings group [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stream_password_title">Password</string>
|
||||
<!-- Title for stream compatibility on audio sharing page, under stream settings group [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stream_compatibility_title">Improve compatibility</string>
|
||||
<!-- Description for stream compatibility on audio sharing page [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stream_compatibility_description">Helps some devices, like hearing aids, connect by reducing audio quality</string>
|
||||
<!-- Description for stream compatibility on audio sharing page when audio sharing is on [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stream_compatibility_disabled_description">Turns off the audio sharing to config the compatibility</string>
|
||||
<!-- Title for nearby audio group on audio sharing page [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_nearby_audio_title">Listen to nearby audio</string>
|
||||
<!-- Description for audio sharing page footer [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_footer_description">Audio sharing supports Auracast™</string>
|
||||
<!-- Title for stream name dialog [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stream_name_dialog_title">Audio stream name</string>
|
||||
<!-- Title for stream password dialog [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stream_password_dialog_title">Audio stream password</string>
|
||||
<!-- Title for media device group during audio sharing [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_media_device_group_title">Other media devices</string>
|
||||
<!-- Summary for audio sharing on [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_summary_on">On</string>
|
||||
<!-- Summary for audio sharing off [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_summary_off">Off</string>
|
||||
<!-- Title for audio sharing share dialog [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_share_dialog_title">Share your audio</string>
|
||||
<!-- Subtitle for audio sharing share dialog [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_share_dialog_subtitle"><xliff:g example="My buds1" id="device_name1">%1$s</xliff:g> and <xliff:g example="My buds2" id="device_name2">%2$s</xliff:g></string>
|
||||
<!-- Text for audio sharing share button [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_share_button_label">Share audio</string>
|
||||
<!-- Text for audio sharing no thanks button [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_no_thanks_button_label">No thanks</string>
|
||||
<!-- Title for audio sharing share dialog with one device [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_share_with_dialog_title">Share audio with <xliff:g example="My buds" id="device_name">%1$s</xliff:g>?</string>
|
||||
<!-- Title for audio sharing share dialog with more devices [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_share_with_more_dialog_title">Share audio with another device</string>
|
||||
<!-- Text for audio sharing share with button [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_share_with_button_label">Share with <xliff:g example="My buds" id="device_name">%1$s</xliff:g></string>
|
||||
<!-- Text for audio sharing close button [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_close_button_label">Close</string>
|
||||
<!-- Content for audio sharing share dialog with no device, ask users to connect device [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_dialog_connect_device_content">Connect another pair of compatible headphones, or share your stream\'s name and password with the other person</string>
|
||||
<!-- Content for audio sharing share dialog with no device, ask users to pair device [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_dialog_pair_device_content">Pair another set of compatible headphones, or share your audio stream QR code with the other person</string>
|
||||
<!-- Text for sharing audio sharing state [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_sharing_label">Sharing audio</string>
|
||||
<!-- Text for audio sharing pair button [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_pair_button_label">Pair new device</string>
|
||||
<!-- Text for audio sharing qrcode button [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_qrcode_button_label">Show QR code</string>
|
||||
<!-- Title for audio sharing notification [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_notification_title">You\'re sharing audio</string>
|
||||
<!-- Content for audio sharing notification [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_notification_content">People listening can hear your media. They won\'t hear calls.</string>
|
||||
<!-- Text for audio sharing stop button [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stop_button_label">Stop sharing</string>
|
||||
<!-- Text for audio sharing settings button [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_settings_button_label">Settings</string>
|
||||
<!-- Title for audio sharing disconnect dialog [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_disconnect_dialog_title">Choose a device to disconnect</string>
|
||||
<!-- Content for audio sharing disconnect dialog [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_dialog_disconnect_content">Only 2 devices can share audio at a time</string>
|
||||
<!-- Text for audio sharing disconnect device button [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_disconnect_device_button_label">Disconnect <xliff:g example="My buds" id="device_name">%1$s</xliff:g></string>
|
||||
<!-- Title for audio sharing stop dialog [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stop_dialog_title">Connect <xliff:g example="My buds" id="device_name">%1$s</xliff:g> ?</string>
|
||||
<!-- Content for audio sharing stop dialog [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stop_dialog_content">You\'ll stop sharing audio with <xliff:g example="My buds" id="device_name">%1$s</xliff:g></string>
|
||||
<!-- Content for audio sharing stop dialog with two devices [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stop_dialog_with_two_content">You\'ll stop sharing audio with <xliff:g example="My buds1" id="device_name1">%1$s</xliff:g> and <xliff:g example="My buds2" id="device_name2">%2$s</xliff:g></string>
|
||||
<!-- Content for audio sharing stop dialog with more devices [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_stop_dialog_with_more_content">You\'ll stop sharing audio with the connected headphones</string>
|
||||
<!-- Text for audio sharing connect button [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_connect_button_label">Connect</string>
|
||||
<!-- Text for sharing audio stop state [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_sharing_stopped_label">Audio sharing stopped</string>
|
||||
<!-- Title for audio sharing confirm dialog [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_confirm_dialog_title">Connect a compatible device</string>
|
||||
<!-- Content for audio sharing confirm dialog [CHAR LIMIT=none]-->
|
||||
<string name="audio_sharing_comfirm_dialog_content">To start sharing audio, first connect LE Audio headphones to your phone</string>
|
||||
|
||||
<!-- Title for audio streams preference category [CHAR LIMIT=none]-->
|
||||
<string name="audio_streams_category_title">Connect to a LE audio stream</string>
|
||||
<!-- Title for audio streams preference [CHAR LIMIT=none]-->
|
||||
<string name="audio_streams_pref_title">Nearby audio streams</string>
|
||||
<!-- Title for audio streams page [CHAR LIMIT=none]-->
|
||||
<string name="audio_streams_title">Audio streams</string>
|
||||
<!-- Summary for QR code scanning in audio streams page [CHAR LIMIT=none]-->
|
||||
<string name="audio_streams_qr_code_summary">Connect to an audio stream using QR code</string>
|
||||
<!--Text that appears when scanning for nearby audio streams is finished and no streams were found [CHAR LIMIT=40]-->
|
||||
<string name="audio_streams_empty">No nearby audio streams were found.</string>
|
||||
<!-- Disconnect from an audio stream [CHAR LIMIT=none]-->
|
||||
<string name="audio_streams_disconnect">Disconnect</string>
|
||||
<!-- Connect an audio stream [CHAR LIMIT=none]-->
|
||||
<string name="audio_streams_connect">Connect</string>
|
||||
<!-- Hint for QR code process failure [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_qr_code_is_not_valid_format">QR code isn\u0027t a valid format</string>
|
||||
<!-- Le audio QR code scanner sub-title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_qr_code_scanner">To start listening, center the QR code below</string>
|
||||
<!-- The preference summary when add source response is bad code [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_add_source_bad_code_state_summary">Check password and try again</string>
|
||||
<!-- The preference summary when add source response results in general failure [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_add_source_failed_state_summary">Can\u0027t connect. Try again.</string>
|
||||
<!-- The preference summary when waiting for add source response [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_add_source_wait_for_response_summary">Connecting\u2026</string>
|
||||
<!-- The preference summary when waiting for sync [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_wait_for_sync_state_summary">Scanning\u2026</string>
|
||||
<!-- Le audio streams audio lost dialog title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_stream_is_not_available">Audio stream isn\u0027t available</string>
|
||||
<!-- Le audio streams audio lost dialog subtitle [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_is_not_playing">This audio stream isn\u0027t playing anything right now</string>
|
||||
<!-- Le audio streams dialog close [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_close">Close</string>
|
||||
<!-- Le audio streams dialog listen [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_listen">Listen</string>
|
||||
<!-- Le audio streams dialog retry button [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_retry">Try again</string>
|
||||
<!-- Le audio streams confirmation dialog title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_listen_to_audio_stream">Listen to audio stream</string>
|
||||
<!-- Le audio streams confirmation dialog subtitle [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_control_volume">The audio stream will play on <xliff:g example="LE headset" id="device_name">%1$s</xliff:g>. Use this device to control the volume.</string>
|
||||
<!-- Le audio streams failure dialog title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_cannot_listen">Can\u0027t listen to audio stream</string>
|
||||
<!-- Le audio streams confirm dialog default device [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_default_device">connected compatible headphones</string>
|
||||
<!-- Le audio streams activity title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_activity_title">Broadcasts</string>
|
||||
<!-- Le audio streams no password summary [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_no_password_summary">No password</string>
|
||||
<!-- Le audio streams failure dialog subtitle [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_learn_more">Learn more</string>
|
||||
<!-- Le audio streams failure dialog subtitle [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_cannot_play">Can\u0027t play this audio stream on <xliff:g example="LE headset" id="device_name">%1$s</xliff:g>.</string>
|
||||
<!-- The preference summary when add source succeed [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_listening_now">Listening now</string>
|
||||
<!-- Le audio streams service notification leave broadcast text [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_media_service_notification_leave_broadcast_text">Stop listening</string>
|
||||
<!-- Le audio streams no le device dialog title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_no_le_device_title">Connect compatible headphones</string>
|
||||
<!-- Le audio streams no le device dialog button [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_dialog_no_le_device_button">Connect a device</string>
|
||||
<!-- Le audio streams detail page title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_detail_page_title">Audio stream details</string>
|
||||
<!-- Le audio streams qr code page title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_qr_code_page_title">Audio stream QR code</string>
|
||||
<!-- Le audio streams qr code page password text [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_qr_code_page_password">Password: <xliff:g example="123" id="password">%1$s</xliff:g></string>
|
||||
<!-- Le audio streams qr code page description [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_qr_code_page_description">To listen to <xliff:g example="Local Music" id="stream_name">%1$s</xliff:g>, other people can connect compatible headphones to their Android device. They can then scan this QR code.</string>
|
||||
<!-- Le audio streams main page title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_main_page_title">Find an audio stream</string>
|
||||
<!-- Le audio streams main page subtitle [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_main_page_subtitle">Listen to a device that\u0027s sharing audio or to a nearby Auracast broadcast</string>
|
||||
<!-- Le audio streams main page device preference title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_main_page_device_title">Your audio device</string>
|
||||
<!-- Le audio streams main page device preference no device summary [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_main_page_no_device_summary">Connect compatible headphones</string>
|
||||
<!-- Le audio streams main page scanning section title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_main_page_scan_section_title">Audio streams nearby</string>
|
||||
<!-- Le audio streams main page scan qr code preference title [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_main_page_scan_qr_code_title">Scan QR code</string>
|
||||
<!-- Le audio streams main page scan qr code preference summary [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_main_page_scan_qr_code_summary">Start listening by scanning a stream\u0027s QR code</string>
|
||||
<!-- Le audio streams main page password dialog join button [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_main_page_password_dialog_join_button">Listen to stream</string>
|
||||
<!-- Le audio streams main page qr code scanner summary [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_main_page_qr_code_scanner_summary">Scan an audio stream QR code to listen with <xliff:g example="LE headset" id="device_name">%1$s</xliff:g></string>
|
||||
<!-- Le audio streams password dialog [CHAR LIMIT=NONE] -->
|
||||
<string name="audio_streams_main_page_password_dialog_cannot_edit">Can\u0027t edit password while sharing. To change the password, first turn off audio sharing.</string>
|
||||
|
||||
|
||||
<!-- url for learning more about bluetooth audio sharing -->
|
||||
<string name="help_url_audio_sharing" translatable="false"></string>
|
||||
</resources>
|
||||
|
||||
101
res/xml/bluetooth_audio_streams_dialog.xml
Normal file
101
res/xml/bluetooth_audio_streams_dialog.xml
Normal 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>
|
||||
63
res/xml/bluetooth_audio_streams_qr_code.xml
Normal file
63
res/xml/bluetooth_audio_streams_qr_code.xml
Normal 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>
|
||||
81
res/xml/bluetooth_le_audio_sharing.xml
Normal file
81
res/xml/bluetooth_le_audio_sharing.xml
Normal 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>
|
||||
36
res/xml/bluetooth_le_audio_stream_details_fragment.xml
Normal file
36
res/xml/bluetooth_le_audio_stream_details_fragment.xml
Normal 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>
|
||||
46
res/xml/bluetooth_le_audio_streams.xml
Normal file
46
res/xml/bluetooth_le_audio_streams.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +249,12 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
|
||||
public void onDeviceClick(Preference preference) {
|
||||
final CachedBluetoothDevice cachedDevice =
|
||||
((BluetoothDevicePreference) preference).getBluetoothDevice();
|
||||
if (AudioSharingUtils.isFeatureEnabled() && mDialogHandler != null) {
|
||||
mDialogHandler.handleDeviceConnected(cachedDevice, /* userTriggered= */ true);
|
||||
} else {
|
||||
cachedDevice.setActive();
|
||||
}
|
||||
}
|
||||
|
||||
public void init(DashboardFragment fragment) {
|
||||
mFragmentManager = fragment.getParentFragmentManager();
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user