Merge changes from topic "bt-tile" into main
* changes:
Handle bluetooth callback and toggle switch, also moved `getDeviceItems` to background thread.
Filled in devices for BluetoothTileDialog and implemented click callback.
Create BluetoothTileDialog.
diff --git a/packages/SystemUI/res/layout/bluetooth_device_item.xml b/packages/SystemUI/res/layout/bluetooth_device_item.xml
new file mode 100644
index 0000000..4265c4b
--- /dev/null
+++ b/packages/SystemUI/res/layout/bluetooth_device_item.xml
@@ -0,0 +1,68 @@
+<?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:id="@+id/bluetooth_device_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:layout_marginBottom="4dp">
+
+ <LinearLayout
+ android:id="@+id/bluetooth_device"
+ style="@style/BluetoothTileDialog.Device"
+ android:layout_height="@dimen/bluetooth_dialog_device_height"
+ android:paddingEnd="24dp"
+ android:paddingStart="20dp"
+ android:baselineAligned="false">
+
+ <FrameLayout
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical|start"
+ android:clickable="false">
+
+ <ImageView
+ android:id="@+id/bluetooth_device_icon"
+ android:contentDescription="@string/accessibility_bluetooth_device_icon"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center" />
+ </FrameLayout>
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="@dimen/bluetooth_dialog_device_height"
+ android:paddingStart="20dp"
+ android:paddingEnd="24dp"
+ android:layout_marginEnd="30dp"
+ android:layout_weight="1"
+ android:clickable="false"
+ android:gravity="start|center_vertical"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/bluetooth_device_name"
+ style="@style/BluetoothTileDialog.DeviceName"
+ android:textSize="14sp" />
+
+ <TextView
+ android:id="@+id/bluetooth_device_summary"
+ style="@style/BluetoothTileDialog.DeviceSummary" />
+ </LinearLayout>
+ </LinearLayout>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout/bluetooth_tile_dialog.xml b/packages/SystemUI/res/layout/bluetooth_tile_dialog.xml
new file mode 100644
index 0000000..9d14d0f
--- /dev/null
+++ b/packages/SystemUI/res/layout/bluetooth_tile_dialog.xml
@@ -0,0 +1,168 @@
+<?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:id="@+id/root"
+ android:layout_width="@dimen/large_dialog_width"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <LinearLayout
+ style="@style/Widget.SliceView.Panel"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="@dimen/bluetooth_dialog_layout_margin"
+ android:layout_marginTop="24dp"
+ android:gravity="center_vertical|center_horizontal"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/bluetooth_tile_dialog_title"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:ellipsize="end"
+ android:gravity="center_vertical|center_horizontal"
+ android:text="@string/quick_settings_bluetooth_label"
+ android:textAppearance="@style/TextAppearance.Dialog.Title"
+ android:textSize="24sp" />
+
+ <TextView
+ android:id="@+id/bluetooth_tile_dialog_subtitle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:ellipsize="end"
+ android:gravity="center_vertical|center_horizontal"
+ android:maxLines="1"
+ android:text="@string/quick_settings_bluetooth_tile_subtitle"
+ android:textAppearance="@style/TextAppearance.Dialog.Body.Message" />
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/turn_on_bluetooth_layout"
+ style="@style/BluetoothTileDialog.Device"
+ android:layout_height="@dimen/bluetooth_dialog_device_height"
+ android:gravity="center"
+ android:clickable="false"
+ android:focusable="false"
+ android:baselineAligned="false">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:gravity="start|center_vertical"
+ android:orientation="vertical"
+ android:clickable="false">
+ <TextView
+ android:id="@+id/bluetooth_toggle_title"
+ android:text="@string/turn_on_bluetooth"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:gravity="start|center_vertical"
+ android:textAppearance="@style/TextAppearance.Dialog.Body.Message"
+ android:textSize="16sp"/>
+ </LinearLayout>
+
+ <FrameLayout
+ android:layout_width="@dimen/settingslib_switch_track_width"
+ android:layout_height="48dp"
+ android:layout_marginTop="10dp"
+ android:layout_marginBottom="10dp">
+ <Switch
+ android:id="@+id/bluetooth_toggle"
+ android:contentDescription="@string/turn_on_bluetooth"
+ android:switchMinWidth="@dimen/settingslib_switch_track_width"
+ android:layout_gravity="center"
+ android:layout_width="@dimen/settingslib_switch_track_width"
+ android:layout_height="match_parent"
+ android:track="@drawable/settingslib_track_selector"
+ android:thumb="@drawable/settingslib_thumb_selector"
+ android:theme="@style/MainSwitch.Settingslib"/>
+ </FrameLayout>
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/body_layout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/device_list"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:nestedScrollingEnabled="false"
+ android:overScrollMode="never"
+ android:scrollbars="vertical" />
+
+ <LinearLayout
+ android:id="@+id/see_all_layout"
+ style="@style/BluetoothTileDialog.Device"
+ android:layout_height="64dp"
+ android:paddingStart="20dp"
+ android:visibility="gone">
+
+ <FrameLayout
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical|start"
+ android:clickable="false">
+
+ <ImageView
+ android:id="@+id/arrow_forward"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:src="@drawable/ic_arrow_forward"
+ android:importantForAccessibility="no" />
+ </FrameLayout>
+
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginStart="@dimen/bluetooth_dialog_layout_margin"
+ android:clickable="false"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:gravity="start|center_vertical"
+ android:text="@string/see_all_bluetooth_devices"
+ android:textAppearance="@style/TextAppearance.Dialog.Body.Message"
+ android:textSize="14sp" />
+ </FrameLayout>
+ </LinearLayout>
+
+ <Button
+ android:id="@+id/done_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:layout_marginStart="@dimen/dialog_side_padding"
+ android:layout_marginEnd="@dimen/dialog_side_padding"
+ android:layout_marginBottom="@dimen/dialog_bottom_padding"
+ android:text="@string/inline_done_button"
+ android:layout_gravity="end|center_vertical"
+ style="@style/Widget.Dialog.Button"
+ android:maxLines="1"
+ android:ellipsize="end"
+ android:clickable="true"
+ android:focusable="true"/>
+ </LinearLayout>
+</LinearLayout>
\ No newline at end of file
diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml
index 7e9d3f5..88726af 100644
--- a/packages/SystemUI/res/values/dimens.xml
+++ b/packages/SystemUI/res/values/dimens.xml
@@ -1643,6 +1643,11 @@
<!-- Radius of switch track -->
<dimen name="settingslib_switch_track_radius">35dp</dimen>
+ <!-- Bluetooth dialog related dimensions -->
+ <dimen name="bluetooth_dialog_layout_margin">16dp</dimen>
+ <!-- The height of the bluetooth device in bluetooth dialog. -->
+ <dimen name="bluetooth_dialog_device_height">72dp</dimen>
+
<!-- Height percentage of the parent container occupied by the communal view -->
<item name="communal_source_height_percentage" format="float" type="dimen">0.80</item>
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index 29c9767..5f3ddda 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -459,6 +459,9 @@
<!-- Content description of the bluetooth icon when connected for accessibility (not shown on the screen). [CHAR LIMIT=NONE] -->
<string name="accessibility_bluetooth_connected">Bluetooth connected.</string>
+
+ <!-- Content description of the bluetooth device icon. [CHAR LIMIT=NONE] -->
+ <string name="accessibility_bluetooth_device_icon">Bluetooth device icon</string>
<!-- Content description of the bluetooth icon when connecting for accessibility (not shown on the screen). [CHAR LIMIT=NONE] -->
<!-- Content description of the battery when battery state is unknown for accessibility (not shown on the screen). [CHAR LIMIT=NONE] -->
@@ -621,6 +624,17 @@
<!-- QuickSettings: Bluetooth (Off) [CHAR LIMIT=NONE] -->
<!-- QuickSettings: Bluetooth detail panel, text when there are no items [CHAR LIMIT=NONE] -->
<string name="quick_settings_bluetooth_detail_empty_text">No paired devices available</string>
+ <!-- QuickSettings: Bluetooth dialog subtitle [CHAR LIMIT=NONE]-->
+ <string name="quick_settings_bluetooth_tile_subtitle">Tap to connect or disconnect</string>
+ <!-- QuickSettings: Bluetooth dialog see all devices [CHAR LIMIT=NONE]-->
+ <string name="see_all_bluetooth_devices">See all</string>
+ <!-- QuickSettings: Bluetooth dialog turn on Bluetooth [CHAR LIMIT=NONE]-->
+ <string name="turn_on_bluetooth">Use Bluetooth</string>
+ <!-- QuickSettings: Bluetooth dialog device connected default summary [CHAR LIMIT=NONE]-->
+ <string name="quick_settings_bluetooth_device_connected">Connected</string>
+ <!-- QuickSettings: Bluetooth dialog device saved default summary [CHAR LIMIT=NONE]-->
+ <string name="quick_settings_bluetooth_device_saved">Saved</string>
+
<!-- QuickSettings: Bluetooth secondary label for the battery level of a connected device [CHAR LIMIT=20]-->
<string name="quick_settings_bluetooth_secondary_label_battery_level"><xliff:g id="battery_level_as_percentage">%s</xliff:g> battery</string>
<!-- QuickSettings: Bluetooth secondary label for an audio device being connected [CHAR LIMIT=20]-->
diff --git a/packages/SystemUI/res/values/styles.xml b/packages/SystemUI/res/values/styles.xml
index 6991b96..3bd6ab0 100644
--- a/packages/SystemUI/res/values/styles.xml
+++ b/packages/SystemUI/res/values/styles.xml
@@ -15,7 +15,7 @@
-->
<resources xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:androidprv="http://schemas.android.com/apk/prv/res/android">
+ xmlns:androidprv="http://schemas.android.com/apk/prv/res/android">
<style name="TextAppearance.StatusBar.Clock" parent="@*android:style/TextAppearance.StatusBar.Icon">
<item name="android:textSize">@dimen/status_bar_clock_size</item>
@@ -54,10 +54,10 @@
</style>
<style name="TextAppearance.StatusBar.Expanded.EmergencyCallsOnly"
- parent="TextAppearance.StatusBar.Expanded.AboveDateTime" />
+ parent="TextAppearance.StatusBar.Expanded.AboveDateTime" />
<style name="TextAppearance.StatusBar.Expanded.ChargingInfo"
- parent="TextAppearance.StatusBar.Expanded.AboveDateTime" />
+ parent="TextAppearance.StatusBar.Expanded.AboveDateTime" />
<style name="TextAppearance.StatusBar.Expanded.UserSwitcher">
<item name="android:textSize">@dimen/kg_user_switcher_text_size</item>
@@ -645,7 +645,7 @@
</style>
<style name="TextAppearance.HeadsUpStatusBarText"
- parent="@*android:style/TextAppearance.DeviceDefault.Notification.Info">
+ parent="@*android:style/TextAppearance.DeviceDefault.Notification.Info">
</style>
<style name="TextAppearance.QSEdit" >
@@ -699,7 +699,7 @@
</style>
<style name="MediaPlayer.SessionAction"
- parent="@android:style/Widget.Material.Button.Borderless.Small">
+ parent="@android:style/Widget.Material.Button.Borderless.Small">
<item name="android:background">@drawable/qs_media_light_source</item>
<item name="android:tint">?android:attr/textColorPrimary</item>
<item name="android:paddingTop">12dp</item>
@@ -929,12 +929,13 @@
<item name="android:fontFamily">@*android:string/config_bodyFontFamily</item>
</style>
- <style name="Theme.SystemUI.Dialog.Control.DetailPanel" parent="@android:style/Theme.DeviceDefault.Dialog.NoActionBar">
- <item name="android:windowFullscreen">false</item>
- <item name="android:windowIsFloating">false</item>
- <item name="android:windowBackground">@color/controls_task_view_bg</item>
- <item name="android:backgroundDimEnabled">false</item>
- <item name="android:windowAnimationStyle">@android:style/Animation.Dialog</item>
+ <style name="Theme.SystemUI.Dialog.Control.DetailPanel"
+ parent="@android:style/Theme.DeviceDefault.Dialog.NoActionBar">
+ <item name="android:windowFullscreen">false</item>
+ <item name="android:windowIsFloating">false</item>
+ <item name="android:windowBackground">@color/controls_task_view_bg</item>
+ <item name="android:backgroundDimEnabled">false</item>
+ <item name="android:windowAnimationStyle">@android:style/Animation.Dialog</item>
</style>
<style name="Control" />
@@ -1034,17 +1035,17 @@
<style name="Wallet" />
<style name="Wallet.TextAppearance">
- <item name="android:fontFamily">@*android:string/config_headlineFontFamily</item>
- <item name="android:textColor">?android:attr/textColorPrimary</item>
- <item name="android:singleLine">true</item>
- <item name="android:textSize">14sp</item>
+ <item name="android:fontFamily">@*android:string/config_headlineFontFamily</item>
+ <item name="android:textColor">?android:attr/textColorPrimary</item>
+ <item name="android:singleLine">true</item>
+ <item name="android:textSize">14sp</item>
</style>
<style name="Wallet.Theme" parent="@android:style/Theme.DeviceDefault">
- <item name="android:colorBackground">@color/material_dynamic_neutral10</item>
- <item name="android:itemBackground">@color/material_dynamic_neutral20</item>
- <!-- Setting a placeholder will avoid using the SystemUI icon on the splash screen. -->
- <item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_blank</item>
+ <item name="android:colorBackground">@color/material_dynamic_neutral10</item>
+ <item name="android:itemBackground">@color/material_dynamic_neutral20</item>
+ <!-- Setting a placeholder will avoid using the SystemUI icon on the splash screen. -->
+ <item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_blank</item>
</style>
<style name="Animation.InternetDialog" parent="@android:style/Animation.InputMethod">
@@ -1169,7 +1170,7 @@
</style>
<style name="TrimmedHorizontalProgressBar"
- parent="android:Widget.Material.ProgressBar.Horizontal">
+ parent="android:Widget.Material.ProgressBar.Horizontal">
<item name="android:indeterminateDrawable">
@drawable/progress_indeterminate_horizontal_material_trimmed
</item>
@@ -1254,6 +1255,37 @@
<item name="android:textColor">?android:attr/textColorSecondary</item>
</style>
+ <style name="BluetoothTileDialog">
+ <item name="android:layout_width">wrap_content</item>
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:layout_gravity">center_vertical|start</item>
+ </style>
+
+ <style name="BluetoothTileDialog.Device">
+ <item name="android:layout_width">match_parent</item>
+ <item name="android:layout_height">88dp</item>
+ <item name="android:layout_gravity">center_vertical|start</item>
+ <item name="android:layout_marginStart">@dimen/bluetooth_dialog_layout_margin</item>
+ <item name="android:layout_marginEnd">@dimen/bluetooth_dialog_layout_margin</item>
+ <item name="android:paddingStart">22dp</item>
+ <item name="android:paddingEnd">22dp</item>
+ <item name="android:orientation">horizontal</item>
+ <item name="android:focusable">true</item>
+ <item name="android:clickable">true</item>
+ </style>
+
+ <style name="BluetoothTileDialog.DeviceName">
+ <item name="android:textSize">14sp</item>
+ <item name="android:textAppearance">@style/TextAppearance.Dialog.Title</item>
+ </style>
+
+ <style name="BluetoothTileDialog.DeviceSummary">
+ <item name="android:layout_marginEnd">7dp</item>
+ <item name="android:ellipsize">end</item>
+ <item name="android:maxLines">2</item>
+ <item name="android:textAppearance">@style/TextAppearance.Dialog.Body.Message</item>
+ </style>
+
<style name="BroadcastDialog">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
@@ -1397,7 +1429,7 @@
</style>
<style name="PermissionGrantButtonTop"
- parent="@android:style/Widget.DeviceDefault.Button.Borderless.Colored">
+ parent="@android:style/Widget.DeviceDefault.Button.Borderless.Colored">
<item name="android:layout_width">332dp</item>
<item name="android:layout_height">56dp</item>
<item name="android:layout_marginTop">2dp</item>
@@ -1406,7 +1438,7 @@
</style>
<style name="PermissionGrantButtonBottom"
- parent="@android:style/Widget.DeviceDefault.Button.Borderless.Colored">
+ parent="@android:style/Widget.DeviceDefault.Button.Borderless.Colored">
<item name="android:layout_width">332dp</item>
<item name="android:layout_height">56dp</item>
<item name="android:layout_marginTop">2dp</item>
@@ -1465,14 +1497,14 @@
</style>
<style name="TextAppearance.PrivacyDialog.Item.Title"
- parent="@android:style/TextAppearance.DeviceDefault.Medium">
+ parent="@android:style/TextAppearance.DeviceDefault.Medium">
<item name="android:textSize">14sp</item>
<item name="android:lineHeight">20sp</item>
<item name="android:textColor">?androidprv:attr/materialColorOnSurface</item>
</style>
<style name="TextAppearance.PrivacyDialog.Item.Summary"
- parent="@android:style/TextAppearance.DeviceDefault.Small">
+ parent="@android:style/TextAppearance.DeviceDefault.Small">
<item name="android:textSize">14sp</item>
<item name="android:lineHeight">20sp</item>
<item name="android:textColor">?androidprv:attr/materialColorOnSurfaceVariant</item>
@@ -1481,4 +1513,4 @@
<style name="Theme.PrivacyDialog" parent="@style/Theme.SystemUI.Dialog">
<item name="android:colorBackground">?androidprv:attr/materialColorSurfaceContainer</item>
</style>
-</resources>
+</resources>
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java b/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java
index 1be514d..d862f56 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java
@@ -42,6 +42,8 @@
import com.android.systemui.res.R;
import com.android.systemui.dagger.qualifiers.Background;
import com.android.systemui.dagger.qualifiers.Main;
+import com.android.systemui.flags.FeatureFlags;
+import com.android.systemui.flags.Flags;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.plugins.FalsingManager;
import com.android.systemui.plugins.qs.QSTile.BooleanState;
@@ -50,6 +52,7 @@
import com.android.systemui.qs.QsEventLogger;
import com.android.systemui.qs.logging.QSLogger;
import com.android.systemui.qs.tileimpl.QSTileImpl;
+import com.android.systemui.qs.tiles.dialog.bluetooth.BluetoothTileDialogViewModel;
import com.android.systemui.statusbar.policy.BluetoothController;
import java.util.List;
@@ -72,6 +75,10 @@
private final Executor mExecutor;
+ private final BluetoothTileDialogViewModel mDialogViewModel;
+
+ private final FeatureFlags mFeatureFlags;
+
@Inject
public BluetoothTile(
QSHost host,
@@ -83,13 +90,17 @@
StatusBarStateController statusBarStateController,
ActivityStarter activityStarter,
QSLogger qsLogger,
- BluetoothController bluetoothController
+ BluetoothController bluetoothController,
+ FeatureFlags featureFlags,
+ BluetoothTileDialogViewModel dialogViewModel
) {
super(host, uiEventLogger, backgroundLooper, mainHandler, falsingManager, metricsLogger,
statusBarStateController, activityStarter, qsLogger);
mController = bluetoothController;
mController.observe(getLifecycle(), mCallback);
mExecutor = new HandlerExecutor(mainHandler);
+ mFeatureFlags = featureFlags;
+ mDialogViewModel = dialogViewModel;
}
@Override
@@ -99,11 +110,15 @@
@Override
protected void handleClick(@Nullable View view) {
- // Secondary clicks are header clicks, just toggle.
- final boolean isEnabled = mState.value;
- // Immediately enter transient enabling state when turning bluetooth on.
- refreshState(isEnabled ? null : ARG_SHOW_TRANSIENT_ENABLING);
- mController.setBluetoothEnabled(!isEnabled);
+ if (mFeatureFlags.isEnabled(Flags.BLUETOOTH_QS_TILE_DIALOG)) {
+ mDialogViewModel.showDialog(mContext, view);
+ } else {
+ // Secondary clicks are header clicks, just toggle.
+ final boolean isEnabled = mState.value;
+ // Immediately enter transient enabling state when turning bluetooth on.
+ refreshState(isEnabled ? null : ARG_SHOW_TRANSIENT_ENABLING);
+ mController.setBluetoothEnabled(!isEnabled);
+ }
}
@Override
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractor.kt
new file mode 100644
index 0000000..efad9ec
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractor.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.bluetooth.BluetoothAdapter.STATE_OFF
+import android.bluetooth.BluetoothAdapter.STATE_ON
+import com.android.settingslib.bluetooth.BluetoothCallback
+import com.android.settingslib.bluetooth.LocalBluetoothManager
+import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging
+import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+
+/** Holds business logic for the Bluetooth Dialog's bluetooth and device connection state */
+@SysUISingleton
+internal class BluetoothStateInteractor
+@Inject
+constructor(
+ private val localBluetoothManager: LocalBluetoothManager?,
+ @Application private val coroutineScope: CoroutineScope,
+) {
+
+ internal val updateBluetoothStateFlow: StateFlow<Boolean?> =
+ conflatedCallbackFlow {
+ val listener =
+ object : BluetoothCallback {
+ override fun onBluetoothStateChanged(bluetoothState: Int) {
+ if (bluetoothState == STATE_ON || bluetoothState == STATE_OFF) {
+ super.onBluetoothStateChanged(bluetoothState)
+ trySendWithFailureLogging(
+ bluetoothState == STATE_ON,
+ TAG,
+ "onBluetoothStateChanged"
+ )
+ }
+ }
+ }
+ localBluetoothManager?.eventManager?.registerCallback(listener)
+ awaitClose { localBluetoothManager?.eventManager?.unregisterCallback(listener) }
+ }
+ .stateIn(
+ coroutineScope,
+ SharingStarted.WhileSubscribed(replayExpirationMillis = 0),
+ initialValue = null
+ )
+
+ internal var isBluetoothEnabled: Boolean
+ get() = localBluetoothManager?.bluetoothAdapter?.isEnabled == true
+ set(value) {
+ if (isBluetoothEnabled != value) {
+ localBluetoothManager?.bluetoothAdapter?.apply {
+ if (value) enable() else disable()
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "BtStateInteractor"
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialog.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialog.kt
new file mode 100644
index 0000000..7a436a7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialog.kt
@@ -0,0 +1,175 @@
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.Switch
+import android.widget.TextView
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.phone.SystemUIDialog
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Dialog for showing active, connected and saved bluetooth devices. */
+@SysUISingleton
+internal class BluetoothTileDialog
+constructor(
+ private val bluetoothToggleInitialValue: Boolean,
+ private val bluetoothTileDialogCallback: BluetoothTileDialogCallback,
+ context: Context,
+) : SystemUIDialog(context, DEFAULT_THEME, DEFAULT_DISMISS_ON_DEVICE_LOCK) {
+
+ private val mutableBluetoothStateSwitchedFlow: MutableStateFlow<Boolean?> =
+ MutableStateFlow(null)
+ internal val bluetoothStateSwitchedFlow
+ get() = mutableBluetoothStateSwitchedFlow.asStateFlow()
+
+ private val mutableClickedFlow: MutableSharedFlow<Pair<DeviceItem, Int>> =
+ MutableSharedFlow(extraBufferCapacity = 1)
+ internal val deviceItemClickedFlow
+ get() = mutableClickedFlow.asSharedFlow()
+
+ private val deviceItemAdapter: Adapter = Adapter()
+
+ private lateinit var toggleView: Switch
+ private lateinit var doneButton: View
+ private lateinit var seeAllView: View
+ private lateinit var deviceListView: RecyclerView
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(LayoutInflater.from(context).inflate(R.layout.bluetooth_tile_dialog, null))
+
+ toggleView = requireViewById(R.id.bluetooth_toggle)
+ doneButton = requireViewById(R.id.done_button)
+ seeAllView = requireViewById(R.id.see_all_layout)
+ deviceListView = requireViewById<RecyclerView>(R.id.device_list)
+
+ setupToggle()
+ setupRecyclerView()
+
+ doneButton.setOnClickListener { dismiss() }
+ }
+
+ internal fun onDeviceItemUpdated(deviceItem: List<DeviceItem>, showSeeAll: Boolean) {
+ seeAllView.visibility = if (showSeeAll) VISIBLE else GONE
+ deviceItemAdapter.refreshDeviceItemList(deviceItem)
+ }
+
+ internal fun onDeviceItemUpdatedAtPosition(deviceItem: DeviceItem, position: Int) {
+ deviceItemAdapter.refreshDeviceItem(deviceItem, position)
+ }
+
+ internal fun onBluetoothStateUpdated(isEnabled: Boolean) {
+ toggleView.isChecked = isEnabled
+ }
+
+ private fun setupToggle() {
+ toggleView.isChecked = bluetoothToggleInitialValue
+ toggleView.setOnCheckedChangeListener { _, isChecked ->
+ mutableBluetoothStateSwitchedFlow.value = isChecked
+ }
+ }
+
+ private fun setupRecyclerView() {
+ deviceListView.apply {
+ layoutManager = LinearLayoutManager(context)
+ adapter = deviceItemAdapter
+ }
+ }
+
+ internal inner class Adapter : RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() {
+
+ init {
+ setHasStableIds(true)
+ }
+
+ private val deviceItem: MutableList<DeviceItem> = mutableListOf()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeviceItemViewHolder {
+ val view =
+ LayoutInflater.from(parent.context)
+ .inflate(R.layout.bluetooth_device_item, parent, false)
+ return DeviceItemViewHolder(view)
+ }
+
+ override fun getItemCount() = deviceItem.size
+
+ override fun getItemId(position: Int) = position.toLong()
+
+ override fun onBindViewHolder(holder: DeviceItemViewHolder, position: Int) {
+ val item = getItem(position)
+ holder.bind(item, position)
+ }
+
+ internal fun getItem(position: Int) = deviceItem[position]
+
+ internal fun refreshDeviceItemList(updated: List<DeviceItem>) {
+ deviceItem.clear()
+ deviceItem.addAll(updated)
+ notifyDataSetChanged()
+ }
+
+ internal fun refreshDeviceItem(updated: DeviceItem, position: Int) {
+ deviceItem[position] = updated
+ notifyItemChanged(position)
+ }
+
+ internal inner class DeviceItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ private val container = view.requireViewById<View>(R.id.bluetooth_device)
+ private val nameView = view.requireViewById<TextView>(R.id.bluetooth_device_name)
+ private val summaryView = view.requireViewById<TextView>(R.id.bluetooth_device_summary)
+ private val iconView = view.requireViewById<ImageView>(R.id.bluetooth_device_icon)
+
+ internal fun bind(item: DeviceItem, position: Int) {
+ container.apply {
+ isEnabled = item.isEnabled
+ alpha = item.alpha
+ background = item.background
+ setOnClickListener { mutableClickedFlow.tryEmit(Pair(item, position)) }
+ }
+ nameView.text = item.deviceName
+ summaryView.text = item.connectionSummary
+ iconView.apply {
+ item.iconWithDescription?.let {
+ setImageDrawable(it.first)
+ contentDescription = it.second
+ }
+ }
+ }
+ }
+ }
+
+ internal companion object {
+ const val ENABLED_ALPHA = 1.0f
+ const val DISABLED_ALPHA = 0.3f
+ const val MAX_DEVICE_ITEM_ENTRY = 3
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogRepository.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogRepository.kt
new file mode 100644
index 0000000..ea51bee
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogRepository.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.bluetooth.BluetoothAdapter
+import com.android.settingslib.bluetooth.CachedBluetoothDevice
+import com.android.settingslib.bluetooth.LocalBluetoothManager
+import com.android.systemui.dagger.SysUISingleton
+import javax.inject.Inject
+
+/** Repository to get CachedBluetoothDevices for the Bluetooth Dialog. */
+@SysUISingleton
+internal class BluetoothTileDialogRepository
+@Inject
+constructor(
+ private val localBluetoothManager: LocalBluetoothManager?,
+ private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter()
+) {
+ internal val cachedDevices: Collection<CachedBluetoothDevice>
+ get() {
+ return if (
+ localBluetoothManager == null ||
+ bluetoothAdapter == null ||
+ !bluetoothAdapter.isEnabled
+ ) {
+ emptyList()
+ } else {
+ localBluetoothManager.cachedDeviceManager.cachedDevicesCopy
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModel.kt
new file mode 100644
index 0000000..63f0531
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModel.kt
@@ -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.systemui.qs.tiles.dialog.bluetooth
+
+import android.content.Context
+import android.view.View
+import androidx.annotation.VisibleForTesting
+import com.android.systemui.animation.DialogLaunchAnimator
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.qs.tiles.dialog.bluetooth.BluetoothTileDialog.Companion.MAX_DEVICE_ITEM_ENTRY
+import com.android.systemui.statusbar.phone.SystemUIDialog
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+/** ViewModel for Bluetooth Dialog after clicking on the Bluetooth QS tile. */
+@SysUISingleton
+internal class BluetoothTileDialogViewModel
+@Inject
+constructor(
+ private val deviceItemInteractor: DeviceItemInteractor,
+ private val bluetoothStateInteractor: BluetoothStateInteractor,
+ private val dialogLaunchAnimator: DialogLaunchAnimator,
+ @Application private val coroutineScope: CoroutineScope,
+ @Main private val mainDispatcher: CoroutineDispatcher,
+) : BluetoothTileDialogCallback {
+
+ private var job: Job? = null
+
+ @VisibleForTesting internal var dialog: BluetoothTileDialog? = null
+
+ /**
+ * Shows the dialog.
+ *
+ * @param context The context in which the dialog is displayed.
+ * @param view The view from which the dialog is shown.
+ */
+ fun showDialog(context: Context, view: View?) {
+ dismissDialog()
+
+ var updateDeviceItemJob: Job? = null
+
+ job =
+ coroutineScope.launch(mainDispatcher) {
+ dialog = createBluetoothTileDialog(context)
+ view?.let { dialogLaunchAnimator.showFromView(dialog!!, it) } ?: dialog!!.show()
+ updateDeviceItemJob?.cancel()
+ updateDeviceItemJob = launch { deviceItemInteractor.updateDeviceItems(context) }
+
+ bluetoothStateInteractor.updateBluetoothStateFlow
+ .filterNotNull()
+ .onEach {
+ dialog!!.onBluetoothStateUpdated(it)
+ updateDeviceItemJob?.cancel()
+ updateDeviceItemJob = launch {
+ deviceItemInteractor.updateDeviceItems(context)
+ }
+ }
+ .launchIn(this)
+
+ deviceItemInteractor.updateDeviceItemsFlow
+ .onEach {
+ updateDeviceItemJob?.cancel()
+ updateDeviceItemJob = launch {
+ deviceItemInteractor.updateDeviceItems(context)
+ }
+ }
+ .launchIn(this)
+
+ deviceItemInteractor.deviceItemFlow
+ .filterNotNull()
+ .onEach {
+ dialog!!.onDeviceItemUpdated(
+ it.take(MAX_DEVICE_ITEM_ENTRY),
+ showSeeAll = it.size > MAX_DEVICE_ITEM_ENTRY
+ )
+ }
+ .launchIn(this)
+
+ dialog!!
+ .bluetoothStateSwitchedFlow
+ .filterNotNull()
+ .onEach { bluetoothStateInteractor.isBluetoothEnabled = it }
+ .launchIn(this)
+
+ dialog!!
+ .deviceItemClickedFlow
+ .onEach {
+ if (deviceItemInteractor.updateDeviceItemOnClick(it.first)) {
+ dialog!!.onDeviceItemUpdatedAtPosition(it.first, it.second)
+ }
+ }
+ .launchIn(this)
+ }
+ }
+
+ private fun createBluetoothTileDialog(context: Context): BluetoothTileDialog {
+ return BluetoothTileDialog(
+ bluetoothStateInteractor.isBluetoothEnabled,
+ this@BluetoothTileDialogViewModel,
+ context
+ )
+ .apply { SystemUIDialog.registerDismissListener(this) { dismissDialog() } }
+ }
+
+ private fun dismissDialog() {
+ job?.cancel()
+ job = null
+ dialog?.dismiss()
+ dialog = null
+ }
+}
+
+internal interface BluetoothTileDialogCallback {
+ // TODO(b/298124674): Add click events for gear, see all and pair new device.
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItem.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItem.kt
new file mode 100644
index 0000000..03ae5e8
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItem.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.graphics.drawable.Drawable
+import com.android.settingslib.bluetooth.CachedBluetoothDevice
+import com.android.systemui.qs.tiles.dialog.bluetooth.BluetoothTileDialog.Companion.ENABLED_ALPHA
+
+enum class DeviceItemType {
+ AVAILABLE_MEDIA_BLUETOOTH_DEVICE,
+ CONNECTED_BLUETOOTH_DEVICE,
+ SAVED_BLUETOOTH_DEVICE,
+}
+
+data class DeviceItem(
+ val type: DeviceItemType,
+ val cachedBluetoothDevice: CachedBluetoothDevice,
+ val deviceName: String = "",
+ val connectionSummary: String = "",
+ val iconWithDescription: Pair<Drawable, String>? = null,
+ val background: Drawable? = null,
+ var isEnabled: Boolean = true,
+ var alpha: Float = ENABLED_ALPHA
+)
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemFactory.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemFactory.kt
new file mode 100644
index 0000000..fd57fd4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemFactory.kt
@@ -0,0 +1,122 @@
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.bluetooth.BluetoothDevice
+import android.content.Context
+import android.media.AudioManager
+import com.android.settingslib.bluetooth.BluetoothUtils
+import com.android.settingslib.bluetooth.CachedBluetoothDevice
+import com.android.systemui.res.R
+
+private val backgroundOn = R.drawable.settingslib_switch_bar_bg_on
+private val connected = R.string.quick_settings_bluetooth_device_connected
+private val saved = R.string.quick_settings_bluetooth_device_saved
+
+/** Factories to create different types of Bluetooth device items from CachedBluetoothDevice. */
+internal abstract class DeviceItemFactory {
+ abstract fun isFilterMatched(
+ cachedDevice: CachedBluetoothDevice,
+ audioManager: AudioManager?
+ ): Boolean
+
+ abstract fun create(context: Context, cachedDevice: CachedBluetoothDevice): DeviceItem
+}
+
+internal class AvailableMediaDeviceItemFactory : DeviceItemFactory() {
+ override fun isFilterMatched(
+ cachedDevice: CachedBluetoothDevice,
+ audioManager: AudioManager?
+ ): Boolean {
+ return BluetoothUtils.isAvailableMediaBluetoothDevice(cachedDevice, audioManager)
+ }
+
+ // TODO(b/298124674): move create() to the abstract class to reduce duplicate code
+ override fun create(context: Context, cachedDevice: CachedBluetoothDevice): DeviceItem {
+ return DeviceItem(
+ type = DeviceItemType.AVAILABLE_MEDIA_BLUETOOTH_DEVICE,
+ cachedBluetoothDevice = cachedDevice,
+ deviceName = cachedDevice.name,
+ connectionSummary = cachedDevice.connectionSummary.takeUnless { it.isNullOrEmpty() }
+ ?: context.getString(connected),
+ iconWithDescription =
+ BluetoothUtils.getBtClassDrawableWithDescription(context, cachedDevice).let { p ->
+ Pair(p.first, p.second)
+ },
+ background = context.getDrawable(backgroundOn),
+ isEnabled = !cachedDevice.isBusy,
+ alpha =
+ if (cachedDevice.isBusy) BluetoothTileDialog.DISABLED_ALPHA
+ else BluetoothTileDialog.ENABLED_ALPHA,
+ )
+ }
+}
+
+internal class ConnectedDeviceItemFactory : DeviceItemFactory() {
+ override fun isFilterMatched(
+ cachedDevice: CachedBluetoothDevice,
+ audioManager: AudioManager?
+ ): Boolean {
+ return BluetoothUtils.isConnectedBluetoothDevice(cachedDevice, audioManager)
+ }
+
+ override fun create(context: Context, cachedDevice: CachedBluetoothDevice): DeviceItem {
+ return DeviceItem(
+ type = DeviceItemType.CONNECTED_BLUETOOTH_DEVICE,
+ cachedBluetoothDevice = cachedDevice,
+ deviceName = cachedDevice.name,
+ connectionSummary = cachedDevice.connectionSummary.takeUnless { it.isNullOrEmpty() }
+ ?: context.getString(connected),
+ iconWithDescription =
+ BluetoothUtils.getBtClassDrawableWithDescription(context, cachedDevice).let { p ->
+ Pair(p.first, p.second)
+ },
+ background = context.getDrawable(backgroundOn),
+ isEnabled = !cachedDevice.isBusy,
+ alpha =
+ if (cachedDevice.isBusy) BluetoothTileDialog.DISABLED_ALPHA
+ else BluetoothTileDialog.ENABLED_ALPHA,
+ )
+ }
+}
+
+internal class SavedDeviceItemFactory : DeviceItemFactory() {
+ override fun isFilterMatched(
+ cachedDevice: CachedBluetoothDevice,
+ audioManager: AudioManager?
+ ): Boolean {
+ return cachedDevice.bondState == BluetoothDevice.BOND_BONDED && !cachedDevice.isConnected
+ }
+
+ override fun create(context: Context, cachedDevice: CachedBluetoothDevice): DeviceItem {
+ return DeviceItem(
+ type = DeviceItemType.SAVED_BLUETOOTH_DEVICE,
+ cachedBluetoothDevice = cachedDevice,
+ deviceName = cachedDevice.name,
+ connectionSummary = cachedDevice.connectionSummary.takeUnless { it.isNullOrEmpty() }
+ ?: context.getString(saved),
+ iconWithDescription =
+ BluetoothUtils.getBtClassDrawableWithDescription(context, cachedDevice).let { p ->
+ Pair(p.first, p.second)
+ },
+ isEnabled = !cachedDevice.isBusy,
+ alpha =
+ if (cachedDevice.isBusy) BluetoothTileDialog.DISABLED_ALPHA
+ else BluetoothTileDialog.ENABLED_ALPHA,
+ )
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractor.kt
new file mode 100644
index 0000000..6ffb614
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractor.kt
@@ -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.systemui.qs.tiles.dialog.bluetooth
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.content.Context
+import android.media.AudioManager
+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.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging
+import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Background
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.withContext
+
+/** Holds business logic for the Bluetooth Dialog after clicking on the Bluetooth QS tile. */
+@SysUISingleton
+internal class DeviceItemInteractor
+@Inject
+constructor(
+ private val bluetoothTileDialogRepository: BluetoothTileDialogRepository,
+ private val audioManager: AudioManager,
+ private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter(),
+ private val localBluetoothManager: LocalBluetoothManager?,
+ @Application private val coroutineScope: CoroutineScope,
+ @Background private val backgroundDispatcher: CoroutineDispatcher,
+) {
+
+ private val mutableDeviceItemFlow: MutableStateFlow<List<DeviceItem>?> = MutableStateFlow(null)
+ internal val deviceItemFlow
+ get() = mutableDeviceItemFlow.asStateFlow()
+
+ internal val updateDeviceItemsFlow: SharedFlow<Unit> =
+ conflatedCallbackFlow {
+ val listener =
+ object : BluetoothCallback {
+ override fun onActiveDeviceChanged(
+ activeDevice: CachedBluetoothDevice?,
+ bluetoothProfile: Int
+ ) {
+ super.onActiveDeviceChanged(activeDevice, bluetoothProfile)
+ trySendWithFailureLogging(Unit, TAG, "onActiveDeviceChanged")
+ }
+
+ override fun onConnectionStateChanged(
+ cachedDevice: CachedBluetoothDevice?,
+ state: Int
+ ) {
+ super.onConnectionStateChanged(cachedDevice, state)
+ trySendWithFailureLogging(Unit, TAG, "onConnectionStateChanged")
+ }
+
+ override fun onDeviceAdded(cachedDevice: CachedBluetoothDevice) {
+ super.onDeviceAdded(cachedDevice)
+ trySendWithFailureLogging(Unit, TAG, "onDeviceAdded")
+ }
+
+ override fun onProfileConnectionStateChanged(
+ cachedDevice: CachedBluetoothDevice,
+ state: Int,
+ bluetoothProfile: Int
+ ) {
+ super.onProfileConnectionStateChanged(
+ cachedDevice,
+ state,
+ bluetoothProfile
+ )
+ trySendWithFailureLogging(Unit, TAG, "onProfileConnectionStateChanged")
+ }
+ }
+ localBluetoothManager?.eventManager?.registerCallback(listener)
+ awaitClose { localBluetoothManager?.eventManager?.unregisterCallback(listener) }
+ }
+ .shareIn(coroutineScope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0))
+
+ private var deviceItemFactoryList: List<DeviceItemFactory> =
+ listOf(
+ AvailableMediaDeviceItemFactory(),
+ ConnectedDeviceItemFactory(),
+ SavedDeviceItemFactory()
+ )
+
+ private var displayPriority: List<DeviceItemType> =
+ listOf(
+ DeviceItemType.AVAILABLE_MEDIA_BLUETOOTH_DEVICE,
+ DeviceItemType.CONNECTED_BLUETOOTH_DEVICE,
+ DeviceItemType.SAVED_BLUETOOTH_DEVICE,
+ )
+
+ internal suspend fun updateDeviceItems(context: Context) {
+ withContext(backgroundDispatcher) {
+ val mostRecentlyConnectedDevices = bluetoothAdapter?.mostRecentlyConnectedDevices
+
+ mutableDeviceItemFlow.value =
+ bluetoothTileDialogRepository.cachedDevices
+ .mapNotNull { cachedDevice ->
+ deviceItemFactoryList
+ .firstOrNull { it.isFilterMatched(cachedDevice, audioManager) }
+ ?.create(context, cachedDevice)
+ }
+ .sort(displayPriority, mostRecentlyConnectedDevices)
+ }
+ }
+
+ private fun List<DeviceItem>.sort(
+ displayPriority: List<DeviceItemType>,
+ mostRecentlyConnectedDevices: List<BluetoothDevice>?
+ ): List<DeviceItem> {
+ return this.sortedWith(
+ compareBy<DeviceItem> { displayPriority.indexOf(it.type) }
+ .thenBy {
+ mostRecentlyConnectedDevices?.indexOf(it.cachedBluetoothDevice.device) ?: 0
+ }
+ )
+ }
+
+ internal fun updateDeviceItemOnClick(deviceItem: DeviceItem): Boolean {
+ var isClicked = false
+ when (deviceItem.type) {
+ DeviceItemType.AVAILABLE_MEDIA_BLUETOOTH_DEVICE -> {
+ if (!BluetoothUtils.isActiveMediaDevice(deviceItem.cachedBluetoothDevice)) {
+ deviceItem.cachedBluetoothDevice.setActive()
+ isClicked = true
+ }
+ }
+ DeviceItemType.CONNECTED_BLUETOOTH_DEVICE -> {}
+ DeviceItemType.SAVED_BLUETOOTH_DEVICE -> {
+ deviceItem.cachedBluetoothDevice.connect()
+ isClicked = true
+ }
+ }
+ if (isClicked) {
+ deviceItem.isEnabled = false
+ deviceItem.alpha = BluetoothTileDialog.DISABLED_ALPHA
+ }
+ return isClicked
+ }
+
+ internal fun setDeviceItemFactoryListForTesting(list: List<DeviceItemFactory>) {
+ deviceItemFactoryList = list
+ }
+
+ internal fun setDisplayPriorityForTesting(list: List<DeviceItemType>) {
+ displayPriority = list
+ }
+
+ companion object {
+ private const val TAG = "DeviceItemInteractor"
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/BluetoothTileTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/BluetoothTileTest.kt
index 623a8e0..82ee99a 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/BluetoothTileTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/BluetoothTileTest.kt
@@ -14,6 +14,7 @@
import com.android.systemui.res.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.classifier.FalsingManagerFake
+import com.android.systemui.flags.FeatureFlagsClassic
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.plugins.FalsingManager
import com.android.systemui.plugins.qs.QSTile
@@ -22,6 +23,7 @@
import com.android.systemui.qs.QsEventLogger
import com.android.systemui.qs.logging.QSLogger
import com.android.systemui.qs.tileimpl.QSTileImpl
+import com.android.systemui.qs.tiles.dialog.bluetooth.BluetoothTileDialogViewModel
import com.android.systemui.statusbar.policy.BluetoothController
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
@@ -50,6 +52,8 @@
@Mock private lateinit var activityStarter: ActivityStarter
@Mock private lateinit var bluetoothController: BluetoothController
@Mock private lateinit var uiEventLogger: QsEventLogger
+ @Mock private lateinit var featureFlags: FeatureFlagsClassic
+ @Mock private lateinit var bluetoothTileDialogViewModel: BluetoothTileDialogViewModel
private lateinit var testableLooper: TestableLooper
private lateinit var tile: FakeBluetoothTile
@@ -73,6 +77,8 @@
activityStarter,
qsLogger,
bluetoothController,
+ featureFlags,
+ bluetoothTileDialogViewModel
)
tile.initialize()
@@ -220,6 +226,8 @@
activityStarter: ActivityStarter,
qsLogger: QSLogger,
bluetoothController: BluetoothController,
+ featureFlags: FeatureFlagsClassic,
+ bluetoothTileDialogViewModel: BluetoothTileDialogViewModel
) :
BluetoothTile(
qsHost,
@@ -232,6 +240,8 @@
activityStarter,
qsLogger,
bluetoothController,
+ featureFlags,
+ bluetoothTileDialogViewModel
) {
var restrictionChecked: String? = null
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractorTest.kt
new file mode 100644
index 0000000..fc2b7a64
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractorTest.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import androidx.test.filters.SmallTest
+import com.android.settingslib.bluetooth.LocalBluetoothAdapter
+import com.android.settingslib.bluetooth.LocalBluetoothManager
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class BluetoothStateInteractorTest : SysuiTestCase() {
+ @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
+ private val testScope = TestScope()
+
+ private lateinit var bluetoothStateInteractor: BluetoothStateInteractor
+
+ @Mock private lateinit var bluetoothAdapter: LocalBluetoothAdapter
+ @Mock private lateinit var localBluetoothManager: LocalBluetoothManager
+
+ @Before
+ fun setUp() {
+ bluetoothStateInteractor =
+ BluetoothStateInteractor(localBluetoothManager, testScope.backgroundScope)
+ `when`(localBluetoothManager.bluetoothAdapter).thenReturn(bluetoothAdapter)
+ }
+
+ @Test
+ fun testGet_isBluetoothEnabled() {
+ testScope.runTest {
+ `when`(bluetoothAdapter.isEnabled).thenReturn(true)
+
+ assertThat(bluetoothStateInteractor.isBluetoothEnabled).isTrue()
+ }
+ }
+
+ @Test
+ fun testGet_isBluetoothDisabled() {
+ testScope.runTest {
+ `when`(bluetoothAdapter.isEnabled).thenReturn(false)
+
+ assertThat(bluetoothStateInteractor.isBluetoothEnabled).isFalse()
+ }
+ }
+
+ @Test
+ fun testSet_bluetoothEnabled() {
+ testScope.runTest {
+ `when`(bluetoothAdapter.isEnabled).thenReturn(false)
+
+ bluetoothStateInteractor.isBluetoothEnabled = true
+ verify(bluetoothAdapter).enable()
+ }
+ }
+
+ @Test
+ fun testSet_bluetoothNoChange() {
+ testScope.runTest {
+ `when`(bluetoothAdapter.isEnabled).thenReturn(false)
+
+ bluetoothStateInteractor.isBluetoothEnabled = false
+ verify(bluetoothAdapter, never()).enable()
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogRepositoryTest.kt
new file mode 100644
index 0000000..da8f60a
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogRepositoryTest.kt
@@ -0,0 +1,79 @@
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.bluetooth.BluetoothAdapter
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import androidx.test.filters.SmallTest
+import com.android.settingslib.bluetooth.CachedBluetoothDevice
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager
+import com.android.settingslib.bluetooth.LocalBluetoothManager
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class BluetoothTileDialogRepositoryTest : SysuiTestCase() {
+
+ @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ @Mock private lateinit var localBluetoothManager: LocalBluetoothManager
+
+ @Mock private lateinit var bluetoothAdapter: BluetoothAdapter
+
+ @Mock private lateinit var cachedDeviceManager: CachedBluetoothDeviceManager
+
+ @Mock private lateinit var cachedDevicesCopy: Collection<CachedBluetoothDevice>
+
+ private lateinit var repository: BluetoothTileDialogRepository
+
+ @Before
+ fun setUp() {
+ `when`(localBluetoothManager.cachedDeviceManager).thenReturn(cachedDeviceManager)
+ `when`(cachedDeviceManager.cachedDevicesCopy).thenReturn(cachedDevicesCopy)
+
+ repository = BluetoothTileDialogRepository(localBluetoothManager, bluetoothAdapter)
+ }
+
+ @Test
+ fun testCachedDevices_bluetoothOff_emptyList() {
+ `when`(bluetoothAdapter.isEnabled).thenReturn(false)
+
+ val result = repository.cachedDevices
+
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun testCachedDevices_bluetoothOn_returnDevice() {
+ `when`(bluetoothAdapter.isEnabled).thenReturn(true)
+
+ val result = repository.cachedDevices
+
+ assertThat(result).isEqualTo(cachedDevicesCopy)
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogTest.kt
new file mode 100644
index 0000000..e1d177d
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogTest.kt
@@ -0,0 +1,163 @@
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.graphics.drawable.Drawable
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.filters.SmallTest
+import com.android.settingslib.bluetooth.CachedBluetoothDevice
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.qs.tiles.dialog.bluetooth.BluetoothTileDialog.Companion.DISABLED_ALPHA
+import com.android.systemui.qs.tiles.dialog.bluetooth.BluetoothTileDialog.Companion.ENABLED_ALPHA
+import com.android.systemui.res.R
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class BluetoothTileDialogTest : SysuiTestCase() {
+ companion object {
+ const val DEVICE_NAME = "device"
+ const val DEVICE_CONNECTION_SUMMARY = "active"
+ const val ENABLED = true
+ }
+
+ @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ @Mock private lateinit var cachedBluetoothDevice: CachedBluetoothDevice
+
+ @Mock private lateinit var bluetoothTileDialogCallback: BluetoothTileDialogCallback
+
+ @Mock private lateinit var drawable: Drawable
+
+ private lateinit var icon: Pair<Drawable, String>
+ private lateinit var bluetoothTileDialog: BluetoothTileDialog
+ private lateinit var deviceItem: DeviceItem
+
+ @Before
+ fun setUp() {
+ bluetoothTileDialog = BluetoothTileDialog(ENABLED, bluetoothTileDialogCallback, mContext)
+ icon = Pair(drawable, DEVICE_NAME)
+ deviceItem =
+ DeviceItem(
+ type = DeviceItemType.AVAILABLE_MEDIA_BLUETOOTH_DEVICE,
+ cachedBluetoothDevice = cachedBluetoothDevice,
+ deviceName = DEVICE_NAME,
+ connectionSummary = DEVICE_CONNECTION_SUMMARY,
+ iconWithDescription = icon,
+ background = null
+ )
+ `when`(cachedBluetoothDevice.isBusy).thenReturn(false)
+ }
+
+ @Test
+ fun testShowDialog_createRecyclerViewWithAdapter() {
+ bluetoothTileDialog.show()
+
+ val recyclerView = bluetoothTileDialog.findViewById<RecyclerView>(R.id.device_list)
+
+ assertThat(bluetoothTileDialog.isShowing).isTrue()
+ assertThat(recyclerView).isNotNull()
+ assertThat(recyclerView?.visibility).isEqualTo(VISIBLE)
+ assertThat(recyclerView?.adapter).isNotNull()
+ assertThat(recyclerView?.layoutManager is LinearLayoutManager).isTrue()
+ }
+
+ @Test
+ fun testShowDialog_displayBluetoothDevice() {
+ bluetoothTileDialog = BluetoothTileDialog(ENABLED, bluetoothTileDialogCallback, mContext)
+ bluetoothTileDialog.show()
+ bluetoothTileDialog.onDeviceItemUpdated(listOf(deviceItem), false)
+
+ val recyclerView = bluetoothTileDialog.findViewById<RecyclerView>(R.id.device_list)
+ val adapter = recyclerView?.adapter as BluetoothTileDialog.Adapter
+ assertThat(adapter.itemCount).isEqualTo(1)
+ assertThat(adapter.getItem(0).deviceName).isEqualTo(DEVICE_NAME)
+ assertThat(adapter.getItem(0).connectionSummary).isEqualTo(DEVICE_CONNECTION_SUMMARY)
+ assertThat(adapter.getItem(0).iconWithDescription).isEqualTo(icon)
+ }
+
+ @Test
+ fun testDeviceItemViewHolder_cachedDeviceNotBusy() {
+ deviceItem.isEnabled = true
+ deviceItem.alpha = ENABLED_ALPHA
+
+ val view =
+ LayoutInflater.from(mContext).inflate(R.layout.bluetooth_device_item, null, false)
+ val viewHolder =
+ BluetoothTileDialog(ENABLED, bluetoothTileDialogCallback, mContext)
+ .Adapter()
+ .DeviceItemViewHolder(view)
+ viewHolder.bind(deviceItem, 0)
+ val container = view.findViewById<View>(R.id.bluetooth_device)
+
+ assertThat(container).isNotNull()
+ assertThat(container!!.isEnabled).isTrue()
+ assertThat(container.alpha).isEqualTo(ENABLED_ALPHA)
+ assertThat(container.hasOnClickListeners()).isTrue()
+ }
+
+ @Test
+ fun testDeviceItemViewHolder_cachedDeviceBusy() {
+ deviceItem.isEnabled = false
+ deviceItem.alpha = DISABLED_ALPHA
+
+ val view =
+ LayoutInflater.from(mContext).inflate(R.layout.bluetooth_device_item, null, false)
+ val viewHolder =
+ BluetoothTileDialog(ENABLED, bluetoothTileDialogCallback, mContext)
+ .Adapter()
+ .DeviceItemViewHolder(view)
+ viewHolder.bind(deviceItem, 0)
+ val container = view.findViewById<View>(R.id.bluetooth_device)
+
+ assertThat(container).isNotNull()
+ assertThat(container!!.isEnabled).isFalse()
+ assertThat(container.alpha).isEqualTo(DISABLED_ALPHA)
+ assertThat(container.hasOnClickListeners()).isTrue()
+ }
+
+ @Test
+ fun testOnDeviceUpdated_hideSeeAll() {
+ bluetoothTileDialog = BluetoothTileDialog(ENABLED, bluetoothTileDialogCallback, mContext)
+ bluetoothTileDialog.show()
+ bluetoothTileDialog.onDeviceItemUpdated(listOf(deviceItem), false)
+
+ val seeAllLayout = bluetoothTileDialog.findViewById<View>(R.id.see_all_layout)
+ val recyclerView = bluetoothTileDialog.findViewById<RecyclerView>(R.id.device_list)
+ val adapter = recyclerView?.adapter as BluetoothTileDialog.Adapter
+
+ assertThat(seeAllLayout).isNotNull()
+ assertThat(seeAllLayout!!.visibility).isEqualTo(GONE)
+ assertThat(adapter.itemCount).isEqualTo(1)
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModelTest.kt
new file mode 100644
index 0000000..975f1e2
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModelTest.kt
@@ -0,0 +1,143 @@
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.widget.LinearLayout
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.animation.DialogLaunchAnimator
+import com.android.systemui.util.concurrency.FakeExecutor
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.mockito.nullable
+import com.android.systemui.util.time.FakeSystemClock
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.anyBoolean
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class BluetoothTileDialogViewModelTest : SysuiTestCase() {
+
+ @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
+ private val fakeSystemClock = FakeSystemClock()
+ private val backgroundExecutor = FakeExecutor(fakeSystemClock)
+
+ private lateinit var bluetoothTileDialogViewModel: BluetoothTileDialogViewModel
+
+ @Mock private lateinit var bluetoothStateInteractor: BluetoothStateInteractor
+
+ @Mock private lateinit var deviceItemInteractor: DeviceItemInteractor
+
+ @Mock private lateinit var dialogLaunchAnimator: DialogLaunchAnimator
+
+ private lateinit var scheduler: TestCoroutineScheduler
+ private lateinit var dispatcher: CoroutineDispatcher
+ private lateinit var testScope: TestScope
+
+ @Before
+ fun setUp() {
+ scheduler = TestCoroutineScheduler()
+ dispatcher = UnconfinedTestDispatcher(scheduler)
+ testScope = TestScope(dispatcher)
+ bluetoothTileDialogViewModel =
+ BluetoothTileDialogViewModel(
+ deviceItemInteractor,
+ bluetoothStateInteractor,
+ dialogLaunchAnimator,
+ testScope.backgroundScope,
+ dispatcher,
+ )
+ `when`(deviceItemInteractor.deviceItemFlow).thenReturn(MutableStateFlow(null).asStateFlow())
+ `when`(bluetoothStateInteractor.updateBluetoothStateFlow)
+ .thenReturn(MutableStateFlow(null).asStateFlow())
+ `when`(deviceItemInteractor.updateDeviceItemsFlow)
+ .thenReturn(MutableStateFlow(Unit).asStateFlow())
+ `when`(bluetoothStateInteractor.isBluetoothEnabled).thenReturn(true)
+ }
+
+ @Test
+ fun testShowDialog_noAnimation() {
+ testScope.runTest {
+ bluetoothTileDialogViewModel.showDialog(context, null)
+
+ assertThat(bluetoothTileDialogViewModel.dialog).isNotNull()
+ verify(dialogLaunchAnimator, never()).showFromView(any(), any(), any(), any())
+ assertThat(bluetoothTileDialogViewModel.dialog?.isShowing).isTrue()
+ }
+ }
+
+ @Test
+ fun testShowDialog_animated() {
+ testScope.runTest {
+ bluetoothTileDialogViewModel.showDialog(mContext, LinearLayout(mContext))
+
+ assertThat(bluetoothTileDialogViewModel.dialog).isNotNull()
+ verify(dialogLaunchAnimator).showFromView(any(), any(), nullable(), anyBoolean())
+ }
+ }
+
+ @Test
+ fun testShowDialog_animated_callInBackgroundThread() {
+ testScope.runTest {
+ backgroundExecutor.execute {
+ bluetoothTileDialogViewModel.showDialog(mContext, LinearLayout(mContext))
+
+ assertThat(bluetoothTileDialogViewModel.dialog).isNotNull()
+ verify(dialogLaunchAnimator).showFromView(any(), any(), nullable(), anyBoolean())
+ }
+ }
+ }
+
+ @Test
+ fun testShowDialog_fetchDeviceItem() {
+ testScope.runTest {
+ bluetoothTileDialogViewModel.showDialog(context, null)
+
+ assertThat(bluetoothTileDialogViewModel.dialog).isNotNull()
+ verify(deviceItemInteractor).deviceItemFlow
+ }
+ }
+
+ @Test
+ fun testShowDialog_withBluetoothStateValue() {
+ testScope.runTest {
+ bluetoothTileDialogViewModel.showDialog(context, null)
+
+ assertThat(bluetoothTileDialogViewModel.dialog).isNotNull()
+ verify(bluetoothStateInteractor).updateBluetoothStateFlow
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemFactoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemFactoryTest.kt
new file mode 100644
index 0000000..3451902
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemFactoryTest.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import androidx.test.filters.SmallTest
+import com.android.settingslib.bluetooth.CachedBluetoothDevice
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class DeviceItemFactoryTest : SysuiTestCase() {
+
+ @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ @Mock private lateinit var cachedDevice: CachedBluetoothDevice
+
+ private val availableMediaDeviceItemFactory = AvailableMediaDeviceItemFactory()
+ private val connectedDeviceItemFactory = ConnectedDeviceItemFactory()
+ private val savedDeviceItemFactory = SavedDeviceItemFactory()
+
+ @Before
+ fun setup() {
+ `when`(cachedDevice.name).thenReturn(DEVICE_NAME)
+ `when`(cachedDevice.connectionSummary).thenReturn(CONNECTION_SUMMARY)
+ }
+
+ @Test
+ fun testAvailableMediaDeviceItemFactory_createFromCachedDevice() {
+ val deviceItem = availableMediaDeviceItemFactory.create(context, cachedDevice)
+
+ assertDeviceItem(deviceItem, DeviceItemType.AVAILABLE_MEDIA_BLUETOOTH_DEVICE)
+ }
+
+ @Test
+ fun testConnectedDeviceItemFactory_createFromCachedDevice() {
+ val deviceItem = connectedDeviceItemFactory.create(context, cachedDevice)
+
+ assertDeviceItem(deviceItem, DeviceItemType.CONNECTED_BLUETOOTH_DEVICE)
+ }
+
+ @Test
+ fun testSavedDeviceItemFactory_createFromCachedDevice() {
+ val deviceItem = savedDeviceItemFactory.create(context, cachedDevice)
+
+ assertDeviceItem(deviceItem, DeviceItemType.SAVED_BLUETOOTH_DEVICE)
+ assertThat(deviceItem.background).isNull()
+ }
+
+ private fun assertDeviceItem(deviceItem: DeviceItem?, deviceItemType: DeviceItemType) {
+ assertThat(deviceItem).isNotNull()
+ assertThat(deviceItem!!.type).isEqualTo(deviceItemType)
+ assertThat(deviceItem.cachedBluetoothDevice).isEqualTo(cachedDevice)
+ assertThat(deviceItem.deviceName).isEqualTo(DEVICE_NAME)
+ assertThat(deviceItem.connectionSummary).isEqualTo(CONNECTION_SUMMARY)
+ }
+
+ companion object {
+ const val DEVICE_NAME = "DeviceName"
+ const val CONNECTION_SUMMARY = "ConnectionSummary"
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractorTest.kt
new file mode 100644
index 0000000..df9914a
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractorTest.kt
@@ -0,0 +1,217 @@
+/*
+ * 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.systemui.qs.tiles.dialog.bluetooth
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.content.Context
+import android.media.AudioManager
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import androidx.test.filters.SmallTest
+import com.android.settingslib.bluetooth.CachedBluetoothDevice
+import com.android.settingslib.bluetooth.LocalBluetoothManager
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class DeviceItemInteractorTest : SysuiTestCase() {
+
+ @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ @Mock private lateinit var bluetoothTileDialogRepository: BluetoothTileDialogRepository
+
+ @Mock private lateinit var cachedDevice1: CachedBluetoothDevice
+
+ @Mock private lateinit var cachedDevice2: CachedBluetoothDevice
+
+ @Mock private lateinit var device1: BluetoothDevice
+
+ @Mock private lateinit var device2: BluetoothDevice
+
+ @Mock private lateinit var deviceItem1: DeviceItem
+
+ @Mock private lateinit var deviceItem2: DeviceItem
+
+ @Mock private lateinit var audioManager: AudioManager
+
+ @Mock private lateinit var adapter: BluetoothAdapter
+
+ @Mock private lateinit var localBluetoothManager: LocalBluetoothManager
+
+ private lateinit var interactor: DeviceItemInteractor
+
+ private lateinit var dispatcher: CoroutineDispatcher
+
+ private lateinit var testScope: TestScope
+
+ @Before
+ fun setUp() {
+ dispatcher = StandardTestDispatcher()
+ testScope = TestScope(dispatcher)
+ interactor =
+ DeviceItemInteractor(
+ bluetoothTileDialogRepository,
+ audioManager,
+ adapter,
+ localBluetoothManager,
+ testScope.backgroundScope,
+ dispatcher
+ )
+
+ `when`(deviceItem1.cachedBluetoothDevice).thenReturn(cachedDevice1)
+ `when`(deviceItem2.cachedBluetoothDevice).thenReturn(cachedDevice2)
+ `when`(cachedDevice1.device).thenReturn(device1)
+ `when`(cachedDevice2.device).thenReturn(device2)
+ `when`(bluetoothTileDialogRepository.cachedDevices)
+ .thenReturn(listOf(cachedDevice1, cachedDevice2))
+ }
+
+ @Test
+ fun testUpdateDeviceItems_noCachedDevice_returnEmpty() {
+ testScope.runTest {
+ `when`(bluetoothTileDialogRepository.cachedDevices).thenReturn(emptyList())
+ interactor.setDeviceItemFactoryListForTesting(
+ listOf(createFactory({ true }, deviceItem1))
+ )
+
+ interactor.updateDeviceItems(mContext)
+
+ assertThat(interactor.deviceItemFlow.value).isEmpty()
+ }
+ }
+
+ @Test
+ fun testUpdateDeviceItems_hasCachedDevice_filterNotMatch_returnEmpty() {
+ testScope.runTest {
+ `when`(bluetoothTileDialogRepository.cachedDevices).thenReturn(listOf(cachedDevice1))
+ interactor.setDeviceItemFactoryListForTesting(
+ listOf(createFactory({ false }, deviceItem1))
+ )
+
+ interactor.updateDeviceItems(mContext)
+
+ assertThat(interactor.deviceItemFlow.value).isEmpty()
+ }
+ }
+
+ @Test
+ fun testUpdateDeviceItems_hasCachedDevice_filterMatch_returnDeviceItem() {
+ testScope.runTest {
+ `when`(bluetoothTileDialogRepository.cachedDevices).thenReturn(listOf(cachedDevice1))
+ interactor.setDeviceItemFactoryListForTesting(
+ listOf(createFactory({ true }, deviceItem1))
+ )
+
+ interactor.updateDeviceItems(mContext)
+
+ assertThat(interactor.deviceItemFlow.value).hasSize(1)
+ assertThat(interactor.deviceItemFlow.value!![0]).isEqualTo(deviceItem1)
+ }
+ }
+
+ @Test
+ fun testUpdateDeviceItems_hasCachedDevice_filterMatch_returnMultipleDeviceItem() {
+ testScope.runTest {
+ `when`(adapter.mostRecentlyConnectedDevices).thenReturn(null)
+ interactor.setDeviceItemFactoryListForTesting(
+ listOf(createFactory({ false }, deviceItem1), createFactory({ true }, deviceItem2))
+ )
+
+ interactor.updateDeviceItems(mContext)
+
+ assertThat(interactor.deviceItemFlow.value).hasSize(2)
+ assertThat(interactor.deviceItemFlow.value!![0]).isEqualTo(deviceItem2)
+ assertThat(interactor.deviceItemFlow.value!![1]).isEqualTo(deviceItem2)
+ }
+ }
+
+ @Test
+ fun testUpdateDeviceItems_sortByDisplayPriority() {
+ testScope.runTest {
+ `when`(adapter.mostRecentlyConnectedDevices).thenReturn(null)
+ interactor.setDeviceItemFactoryListForTesting(
+ listOf(
+ createFactory({ cachedDevice -> cachedDevice.device == device1 }, deviceItem1),
+ createFactory({ cachedDevice -> cachedDevice.device == device2 }, deviceItem2)
+ )
+ )
+ interactor.setDisplayPriorityForTesting(
+ listOf(
+ DeviceItemType.SAVED_BLUETOOTH_DEVICE,
+ DeviceItemType.CONNECTED_BLUETOOTH_DEVICE
+ )
+ )
+ `when`(deviceItem1.type).thenReturn(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE)
+ `when`(deviceItem2.type).thenReturn(DeviceItemType.SAVED_BLUETOOTH_DEVICE)
+
+ interactor.updateDeviceItems(mContext)
+
+ assertThat(interactor.deviceItemFlow.value).isEqualTo(listOf(deviceItem2, deviceItem1))
+ }
+ }
+
+ @Test
+ fun testUpdateDeviceItems_sameType_sortByRecentlyConnected() {
+ testScope.runTest {
+ `when`(adapter.mostRecentlyConnectedDevices).thenReturn(listOf(device2, device1))
+ interactor.setDeviceItemFactoryListForTesting(
+ listOf(
+ createFactory({ cachedDevice -> cachedDevice.device == device1 }, deviceItem1),
+ createFactory({ cachedDevice -> cachedDevice.device == device2 }, deviceItem2)
+ )
+ )
+ interactor.setDisplayPriorityForTesting(
+ listOf(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE)
+ )
+ `when`(deviceItem1.type).thenReturn(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE)
+ `when`(deviceItem2.type).thenReturn(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE)
+
+ interactor.updateDeviceItems(mContext)
+
+ assertThat(interactor.deviceItemFlow.value).isEqualTo(listOf(deviceItem2, deviceItem1))
+ }
+ }
+
+ private fun createFactory(
+ isFilterMatchFunc: (CachedBluetoothDevice) -> Boolean,
+ deviceItem: DeviceItem
+ ): DeviceItemFactory {
+ return object : DeviceItemFactory() {
+ override fun isFilterMatched(
+ cachedDevice: CachedBluetoothDevice,
+ audioManager: AudioManager?
+ ) = isFilterMatchFunc(cachedDevice)
+
+ override fun create(context: Context, cachedDevice: CachedBluetoothDevice) = deviceItem
+ }
+ }
+}