Create BluetoothDetailsContentManager to support tile details view.
Extract the non-dialog related logic from BluetoothTileDialogDelegate and put it in BluetoothDetailsContentManager.
Bug: 378513956
Flag: NONE refactor
Test: BluetoothDetailsContentManagerTest, BluetoothTileDialogDelegaTetest, BluetoothTileDialogViewModelTest
No-Typo-Check: CUJ in this CL is not a typo
Change-Id: I22ea76d631e0836ca78c20a8f5b6b1ea6d8c667f
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp
index 0a7d880..1f2890c 100644
--- a/packages/SystemUI/Android.bp
+++ b/packages/SystemUI/Android.bp
@@ -314,6 +314,7 @@
"tests/src/**/systemui/statusbar/policy/WalletControllerImplTest.kt",
"tests/src/**/keyguard/ClockEventControllerTest.kt",
"tests/src/**/systemui/bluetooth/qsdialog/BluetoothStateInteractorTest.kt",
+ "tests/src/**/systemui/bluetooth/qsdialog/BluetoothDetailsContentManagerTest.kt",
"tests/src/**/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegateTest.kt",
"tests/src/**/systemui/bluetooth/qsdialog/BluetoothTileDialogRepositoryTest.kt",
"tests/src/**/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModelTest.kt",
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorImpl.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorImpl.kt
index 0303048..94fca21 100644
--- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorImpl.kt
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/AudioSharingDeviceItemActionInteractorImpl.kt
@@ -53,7 +53,7 @@
private val deviceItemActionInteractorImpl: DeviceItemActionInteractorImpl,
) : DeviceItemActionInteractor {
- override suspend fun onClick(deviceItem: DeviceItem, dialog: SystemUIDialog) {
+ override suspend fun onClick(deviceItem: DeviceItem, dialog: SystemUIDialog?) {
withContext(backgroundDispatcher) {
if (!audioSharingInteractor.audioSharingAvailable()) {
return@withContext deviceItemActionInteractorImpl.onClick(deviceItem, dialog)
@@ -70,10 +70,18 @@
DeviceItemType.AVAILABLE_AUDIO_SHARING_MEDIA_BLUETOOTH_DEVICE -> {
if (audioSharingInteractor.qsDialogImprovementAvailable()) {
withContext(mainDispatcher) {
- delegateFactory
- .create(deviceItem.cachedBluetoothDevice)
- .createDialog()
- .let { dialogTransitionAnimator.showFromDialog(it, dialog) }
+ val audioSharingDialog =
+ delegateFactory
+ .create(deviceItem.cachedBluetoothDevice)
+ .createDialog()
+
+ if (dialog != null) {
+ audioSharingDialog.let {
+ dialogTransitionAnimator.showFromDialog(it, dialog)
+ }
+ } else {
+ audioSharingDialog.show()
+ }
}
} else {
launchSettings(deviceItem.cachedBluetoothDevice.device, dialog)
@@ -141,7 +149,7 @@
)
}
- private fun launchSettings(device: BluetoothDevice, dialog: SystemUIDialog) {
+ private fun launchSettings(device: BluetoothDevice, dialog: SystemUIDialog?) {
val intent =
Intent(Settings.ACTION_BLUETOOTH_SETTINGS).apply {
putExtra(
@@ -155,7 +163,8 @@
activityStarter.postStartActivityDismissingKeyguard(
intent,
0,
- dialogTransitionAnimator.createActivityTransitionController(dialog),
+ if (dialog == null) null
+ else dialogTransitionAnimator.createActivityTransitionController(dialog),
)
}
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt
new file mode 100644
index 0000000..0be28f3
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt
@@ -0,0 +1,442 @@
+/*
+ * 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.systemui.bluetooth.qsdialog
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.AccessibilityDelegate
+import android.view.View.GONE
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.view.accessibility.AccessibilityNodeInfo
+import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.ProgressBar
+import android.widget.Switch
+import android.widget.TextView
+import androidx.annotation.StringRes
+import androidx.recyclerview.widget.AsyncListDiffer
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.android.internal.R as InternalR
+import com.android.internal.logging.UiEventLogger
+import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.res.R
+import com.android.systemui.util.time.SystemClock
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.withContext
+
+data class DeviceItemClick(val deviceItem: DeviceItem, val clickedView: View, val target: Target) {
+ enum class Target {
+ ENTIRE_ROW,
+ ACTION_ICON,
+ }
+}
+
+/** View content manager for showing active, connected and saved bluetooth devices. */
+class BluetoothDetailsContentManager
+@AssistedInject
+internal constructor(
+ @Assisted private val initialUiProperties: BluetoothTileDialogViewModel.UiProperties,
+ @Assisted private val cachedContentHeight: Int,
+ @Assisted private val bluetoothTileDialogCallback: BluetoothTileDialogCallback,
+ @Assisted private val isInDialog: Boolean,
+ @Assisted private val doneButtonCallback: () -> Unit,
+ @Main private val mainDispatcher: CoroutineDispatcher,
+ private val systemClock: SystemClock,
+ private val uiEventLogger: UiEventLogger,
+ private val logger: BluetoothTileDialogLogger,
+) {
+
+ private val mutableBluetoothStateToggle: MutableStateFlow<Boolean?> = MutableStateFlow(null)
+ internal val bluetoothStateToggle
+ get() = mutableBluetoothStateToggle.asStateFlow()
+
+ private val mutableBluetoothAutoOnToggle: MutableStateFlow<Boolean?> = MutableStateFlow(null)
+ internal val bluetoothAutoOnToggle
+ get() = mutableBluetoothAutoOnToggle.asStateFlow()
+
+ private val mutableDeviceItemClick: MutableStateFlow<DeviceItemClick?> = MutableStateFlow(null)
+ internal val deviceItemClick
+ get() = mutableDeviceItemClick.asStateFlow()
+
+ private val mutableContentHeight: MutableStateFlow<Int?> = MutableStateFlow(null)
+ internal val contentHeight
+ get() = mutableContentHeight.asStateFlow()
+
+ private val deviceItemAdapter: Adapter = Adapter()
+
+ private var lastUiUpdateMs: Long = -1
+
+ private var lastItemRow: Int = -1
+
+ // UI Components
+ private lateinit var contentView: View
+ private lateinit var doneButton: Button
+ private lateinit var bluetoothToggle: Switch
+ private lateinit var subtitleTextView: TextView
+ private lateinit var seeAllButton: View
+ private lateinit var pairNewDeviceButton: View
+ private lateinit var deviceListView: RecyclerView
+ private lateinit var autoOnToggle: Switch
+ private lateinit var autoOnToggleLayout: View
+ private lateinit var autoOnToggleInfoTextView: TextView
+ private lateinit var audioSharingButton: Button
+ private lateinit var progressBarAnimation: ProgressBar
+ private lateinit var progressBarBackground: View
+ private lateinit var scrollViewContent: View
+
+ @AssistedFactory
+ internal interface Factory {
+ fun create(
+ initialUiProperties: BluetoothTileDialogViewModel.UiProperties,
+ cachedContentHeight: Int,
+ dialogCallback: BluetoothTileDialogCallback,
+ isInDialog: Boolean,
+ doneButtonCallback: () -> Unit,
+ ): BluetoothDetailsContentManager
+ }
+
+ fun bind(contentView: View) {
+ this.contentView = contentView
+
+ doneButton = contentView.requireViewById(R.id.done_button)
+ bluetoothToggle = contentView.requireViewById(R.id.bluetooth_toggle)
+ subtitleTextView = contentView.requireViewById(R.id.bluetooth_tile_dialog_subtitle)
+ seeAllButton = contentView.requireViewById(R.id.see_all_button)
+ pairNewDeviceButton = contentView.requireViewById(R.id.pair_new_device_button)
+ deviceListView = contentView.requireViewById(R.id.device_list)
+ autoOnToggle = contentView.requireViewById(R.id.bluetooth_auto_on_toggle)
+ autoOnToggleLayout = contentView.requireViewById(R.id.bluetooth_auto_on_toggle_layout)
+ autoOnToggleInfoTextView =
+ contentView.requireViewById(R.id.bluetooth_auto_on_toggle_info_text)
+ audioSharingButton = contentView.requireViewById(R.id.audio_sharing_button)
+ progressBarAnimation =
+ contentView.requireViewById(R.id.bluetooth_tile_dialog_progress_animation)
+ progressBarBackground =
+ contentView.requireViewById(R.id.bluetooth_tile_dialog_progress_background)
+ scrollViewContent = contentView.requireViewById(R.id.scroll_view)
+
+ setupToggle()
+ setupRecyclerView()
+ setupDoneButton()
+
+ subtitleTextView.text = contentView.context.getString(initialUiProperties.subTitleResId)
+ seeAllButton.setOnClickListener { bluetoothTileDialogCallback.onSeeAllClicked(it) }
+ pairNewDeviceButton.setOnClickListener {
+ bluetoothTileDialogCallback.onPairNewDeviceClicked(it)
+ }
+ audioSharingButton.apply {
+ setOnClickListener { bluetoothTileDialogCallback.onAudioSharingButtonClicked(it) }
+ accessibilityDelegate =
+ object : AccessibilityDelegate() {
+ override fun onInitializeAccessibilityNodeInfo(
+ host: View,
+ info: AccessibilityNodeInfo,
+ ) {
+ super.onInitializeAccessibilityNodeInfo(host, info)
+ info.addAction(
+ AccessibilityAction(
+ AccessibilityAction.ACTION_CLICK.id,
+ contentView.context.getString(
+ R.string
+ .quick_settings_bluetooth_audio_sharing_button_accessibility
+ ),
+ )
+ )
+ }
+ }
+ }
+ scrollViewContent.apply {
+ minimumHeight =
+ resources.getDimensionPixelSize(initialUiProperties.scrollViewMinHeightResId)
+ layoutParams.height = maxOf(cachedContentHeight, minimumHeight)
+ }
+ }
+
+ fun start() {
+ lastUiUpdateMs = systemClock.elapsedRealtime()
+ }
+
+ fun releaseView() {
+ mutableContentHeight.value = scrollViewContent.measuredHeight
+ }
+
+ internal suspend fun animateProgressBar(animate: Boolean) {
+ withContext(mainDispatcher) {
+ if (animate) {
+ showProgressBar()
+ } else {
+ delay(PROGRESS_BAR_ANIMATION_DURATION_MS)
+ hideProgressBar()
+ }
+ }
+ }
+
+ internal suspend fun onDeviceItemUpdated(
+ deviceItem: List<DeviceItem>,
+ showSeeAll: Boolean,
+ showPairNewDevice: Boolean,
+ ) {
+ withContext(mainDispatcher) {
+ val start = systemClock.elapsedRealtime()
+ val itemRow = deviceItem.size + showSeeAll.toInt() + showPairNewDevice.toInt()
+ // If not the first load, add a slight delay for smoother dialog height change
+ if (itemRow != lastItemRow && lastItemRow != -1) {
+ delay(MIN_HEIGHT_CHANGE_INTERVAL_MS - (start - lastUiUpdateMs))
+ }
+ if (isActive) {
+ deviceItemAdapter.refreshDeviceItemList(deviceItem) {
+ seeAllButton.visibility = if (showSeeAll) VISIBLE else GONE
+ pairNewDeviceButton.visibility = if (showPairNewDevice) VISIBLE else GONE
+ // Update the height after data is updated
+ scrollViewContent.layoutParams.height = WRAP_CONTENT
+ lastUiUpdateMs = systemClock.elapsedRealtime()
+ lastItemRow = itemRow
+ logger.logDeviceUiUpdate(lastUiUpdateMs - start)
+ }
+ }
+ }
+ }
+
+ internal fun onBluetoothStateUpdated(
+ isEnabled: Boolean,
+ uiProperties: BluetoothTileDialogViewModel.UiProperties,
+ ) {
+ bluetoothToggle.apply {
+ isChecked = isEnabled
+ setEnabled(true)
+ alpha = ENABLED_ALPHA
+ }
+ subtitleTextView.text = contentView.context.getString(uiProperties.subTitleResId)
+ autoOnToggleLayout.visibility = uiProperties.autoOnToggleVisibility
+ }
+
+ internal fun onBluetoothAutoOnUpdated(isEnabled: Boolean, @StringRes infoResId: Int) {
+ autoOnToggle.isChecked = isEnabled
+ autoOnToggleInfoTextView.text = contentView.context.getString(infoResId)
+ }
+
+ internal fun onAudioSharingButtonUpdated(visibility: Int, label: String?, isActive: Boolean) {
+ audioSharingButton.apply {
+ this.visibility = visibility
+ label?.let { text = it }
+ this.isActivated = isActive
+ }
+ }
+
+ private fun setupToggle() {
+ bluetoothToggle.setOnCheckedChangeListener { view, isChecked ->
+ mutableBluetoothStateToggle.value = isChecked
+ view.apply {
+ isEnabled = false
+ alpha = DISABLED_ALPHA
+ }
+ logger.logBluetoothState(BluetoothStateStage.USER_TOGGLED, isChecked.toString())
+ uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_TOGGLE_CLICKED)
+ }
+
+ autoOnToggleLayout.visibility = initialUiProperties.autoOnToggleVisibility
+ autoOnToggle.setOnCheckedChangeListener { _, isChecked ->
+ mutableBluetoothAutoOnToggle.value = isChecked
+ uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_AUTO_ON_TOGGLE_CLICKED)
+ }
+ }
+
+ private fun setupDoneButton() {
+ if (isInDialog) {
+ doneButton.setOnClickListener { doneButtonCallback() }
+ } else {
+ doneButton.visibility = GONE
+ }
+ }
+
+ private fun setupRecyclerView() {
+ deviceListView.apply {
+ layoutManager = LinearLayoutManager(contentView.context)
+ adapter = deviceItemAdapter
+ }
+ }
+
+ private fun showProgressBar() {
+ if (progressBarAnimation.visibility != VISIBLE) {
+ progressBarAnimation.visibility = VISIBLE
+ progressBarBackground.visibility = INVISIBLE
+ }
+ }
+
+ private fun hideProgressBar() {
+ if (progressBarAnimation.visibility != INVISIBLE) {
+ progressBarAnimation.visibility = INVISIBLE
+ progressBarBackground.visibility = VISIBLE
+ }
+ }
+
+ internal inner class Adapter : RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() {
+
+ private val diffUtilCallback =
+ object : DiffUtil.ItemCallback<DeviceItem>() {
+ override fun areItemsTheSame(
+ deviceItem1: DeviceItem,
+ deviceItem2: DeviceItem,
+ ): Boolean {
+ return deviceItem1.cachedBluetoothDevice == deviceItem2.cachedBluetoothDevice
+ }
+
+ override fun areContentsTheSame(
+ deviceItem1: DeviceItem,
+ deviceItem2: DeviceItem,
+ ): Boolean {
+ return deviceItem1.type == deviceItem2.type &&
+ deviceItem1.cachedBluetoothDevice == deviceItem2.cachedBluetoothDevice &&
+ deviceItem1.deviceName == deviceItem2.deviceName &&
+ deviceItem1.connectionSummary == deviceItem2.connectionSummary &&
+ // Ignored the icon drawable
+ deviceItem1.iconWithDescription?.second ==
+ deviceItem2.iconWithDescription?.second &&
+ deviceItem1.background == deviceItem2.background &&
+ deviceItem1.isEnabled == deviceItem2.isEnabled &&
+ deviceItem1.actionAccessibilityLabel == deviceItem2.actionAccessibilityLabel
+ }
+ }
+
+ private val asyncListDiffer = AsyncListDiffer(this, diffUtilCallback)
+
+ 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() = asyncListDiffer.currentList.size
+
+ override fun onBindViewHolder(holder: DeviceItemViewHolder, position: Int) {
+ val item = getItem(position)
+ holder.bind(item)
+ }
+
+ internal fun getItem(position: Int) = asyncListDiffer.currentList[position]
+
+ internal fun refreshDeviceItemList(updated: List<DeviceItem>, callback: () -> Unit) {
+ asyncListDiffer.submitList(updated, callback)
+ }
+
+ internal inner class DeviceItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ private val container = view.requireViewById<View>(R.id.bluetooth_device_row)
+ 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)
+ private val actionIcon = view.requireViewById<ImageView>(R.id.gear_icon_image)
+ private val actionIconView = view.requireViewById<View>(R.id.gear_icon)
+ private val divider = view.requireViewById<View>(R.id.divider)
+
+ internal fun bind(item: DeviceItem) {
+ container.apply {
+ isEnabled = item.isEnabled
+ background = item.background?.let { context.getDrawable(it) }
+ setOnClickListener {
+ mutableDeviceItemClick.value =
+ DeviceItemClick(item, it, DeviceItemClick.Target.ENTIRE_ROW)
+ uiEventLogger.log(BluetoothTileDialogUiEvent.DEVICE_CLICKED)
+ }
+
+ // updating icon colors
+ val tintColor =
+ context.getColor(
+ if (item.isActive) InternalR.color.materialColorOnPrimaryContainer
+ else InternalR.color.materialColorOnSurface
+ )
+
+ // update icons
+ iconView.apply {
+ item.iconWithDescription?.let {
+ setImageDrawable(it.first)
+ contentDescription = it.second
+ }
+ }
+
+ actionIcon.setImageResource(item.actionIconRes)
+ actionIcon.drawable?.setTint(tintColor)
+
+ divider.setBackgroundColor(tintColor)
+
+ // update text styles
+ nameView.setTextAppearance(
+ if (item.isActive) R.style.TextAppearance_BluetoothTileDialog_Active
+ else R.style.TextAppearance_BluetoothTileDialog
+ )
+ summaryView.setTextAppearance(
+ if (item.isActive) R.style.TextAppearance_BluetoothTileDialog_Active
+ else R.style.TextAppearance_BluetoothTileDialog
+ )
+
+ accessibilityDelegate =
+ object : AccessibilityDelegate() {
+ override fun onInitializeAccessibilityNodeInfo(
+ host: View,
+ info: AccessibilityNodeInfo,
+ ) {
+ super.onInitializeAccessibilityNodeInfo(host, info)
+ info.addAction(
+ AccessibilityAction(
+ AccessibilityAction.ACTION_CLICK.id,
+ item.actionAccessibilityLabel,
+ )
+ )
+ }
+ }
+ }
+ nameView.text = item.deviceName
+ summaryView.text = item.connectionSummary
+
+ actionIconView.setOnClickListener {
+ mutableDeviceItemClick.value =
+ DeviceItemClick(item, it, DeviceItemClick.Target.ACTION_ICON)
+ }
+ }
+ }
+ }
+
+ internal companion object {
+ const val MIN_HEIGHT_CHANGE_INTERVAL_MS = 800L
+ const val ACTION_BLUETOOTH_DEVICE_DETAILS =
+ "com.android.settings.BLUETOOTH_DEVICE_DETAIL_SETTINGS"
+ const val ACTION_PREVIOUSLY_CONNECTED_DEVICE =
+ "com.android.settings.PREVIOUSLY_CONNECTED_DEVICE"
+ const val ACTION_PAIR_NEW_DEVICE = "android.settings.BLUETOOTH_PAIRING_SETTINGS"
+ const val ACTION_AUDIO_SHARING = "com.android.settings.BLUETOOTH_AUDIO_SHARING_SETTINGS"
+ const val DISABLED_ALPHA = 0.3f
+ const val ENABLED_ALPHA = 1f
+ const val PROGRESS_BAR_ANIMATION_DURATION_MS = 1500L
+
+ private fun Boolean.toInt(): Int {
+ return if (this) 1 else 0
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegate.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegate.kt
index 56caddf..3e61c45 100644
--- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegate.kt
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegate.kt
@@ -18,50 +18,14 @@
import android.os.Bundle
import android.view.LayoutInflater
-import android.view.View
-import android.view.View.AccessibilityDelegate
-import android.view.View.GONE
-import android.view.View.INVISIBLE
-import android.view.View.VISIBLE
-import android.view.ViewGroup
-import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
-import android.view.accessibility.AccessibilityNodeInfo
-import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction
-import android.widget.Button
-import android.widget.ImageView
-import android.widget.ProgressBar
-import android.widget.Switch
-import android.widget.TextView
-import androidx.annotation.StringRes
-import androidx.recyclerview.widget.AsyncListDiffer
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import com.android.internal.R as InternalR
import com.android.internal.logging.UiEventLogger
-import com.android.systemui.dagger.qualifiers.Main
+import com.android.systemui.qs.flags.QsDetailedView
import com.android.systemui.res.R
import com.android.systemui.shade.domain.interactor.ShadeDialogContextInteractor
import com.android.systemui.statusbar.phone.SystemUIDialog
-import com.android.systemui.util.time.SystemClock
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.withContext
-
-data class DeviceItemClick(val deviceItem: DeviceItem, val clickedView: View, val target: Target) {
- enum class Target {
- ENTIRE_ROW,
- ACTION_ICON,
- }
-}
/** Dialog for showing active, connected and saved bluetooth devices. */
class BluetoothTileDialogDelegate
@@ -71,37 +35,13 @@
@Assisted private val cachedContentHeight: Int,
@Assisted private val bluetoothTileDialogCallback: BluetoothTileDialogCallback,
@Assisted private val dismissListener: Runnable,
- @Main private val mainDispatcher: CoroutineDispatcher,
- private val systemClock: SystemClock,
private val uiEventLogger: UiEventLogger,
- private val logger: BluetoothTileDialogLogger,
private val systemuiDialogFactory: SystemUIDialog.Factory,
private val shadeDialogContextInteractor: ShadeDialogContextInteractor,
+ private val bluetoothDetailsContentManagerFactory: BluetoothDetailsContentManager.Factory,
) : SystemUIDialog.Delegate {
- private val mutableBluetoothStateToggle: MutableStateFlow<Boolean?> = MutableStateFlow(null)
- internal val bluetoothStateToggle
- get() = mutableBluetoothStateToggle.asStateFlow()
-
- private val mutableBluetoothAutoOnToggle: MutableStateFlow<Boolean?> = MutableStateFlow(null)
- internal val bluetoothAutoOnToggle
- get() = mutableBluetoothAutoOnToggle.asStateFlow()
-
- private val mutableDeviceItemClick: MutableSharedFlow<DeviceItemClick> =
- MutableSharedFlow(extraBufferCapacity = 1)
- internal val deviceItemClick
- get() = mutableDeviceItemClick.asSharedFlow()
-
- private val mutableContentHeight: MutableSharedFlow<Int> =
- MutableSharedFlow(extraBufferCapacity = 1)
- internal val contentHeight
- get() = mutableContentHeight.asSharedFlow()
-
- private val deviceItemAdapter: Adapter = Adapter()
-
- private var lastUiUpdateMs: Long = -1
-
- private var lastItemRow: Int = -1
+ lateinit var contentManager: BluetoothDetailsContentManager
@AssistedFactory
internal interface Factory {
@@ -114,6 +54,9 @@
}
override fun createDialog(): SystemUIDialog {
+ // If `QsDetailedView` is enabled, it should show the details view.
+ QsDetailedView.assertInLegacyMode()
+
return systemuiDialogFactory.create(this, shadeDialogContextInteractor.context)
}
@@ -127,362 +70,24 @@
dialog.setContentView(this)
}
- setupToggle(dialog)
- setupRecyclerView(dialog)
-
- getSubtitleTextView(dialog).text = context.getString(initialUiProperties.subTitleResId)
- dialog.requireViewById<View>(R.id.done_button).setOnClickListener { dialog.dismiss() }
- getSeeAllButton(dialog).setOnClickListener {
- bluetoothTileDialogCallback.onSeeAllClicked(it)
- }
- getPairNewDeviceButton(dialog).setOnClickListener {
- bluetoothTileDialogCallback.onPairNewDeviceClicked(it)
- }
- getAudioSharingButtonView(dialog).apply {
- setOnClickListener { bluetoothTileDialogCallback.onAudioSharingButtonClicked(it) }
- accessibilityDelegate =
- object : AccessibilityDelegate() {
- override fun onInitializeAccessibilityNodeInfo(
- host: View,
- info: AccessibilityNodeInfo,
- ) {
- super.onInitializeAccessibilityNodeInfo(host, info)
- info.addAction(
- AccessibilityAction(
- AccessibilityAction.ACTION_CLICK.id,
- context.getString(
- R.string
- .quick_settings_bluetooth_audio_sharing_button_accessibility
- ),
- )
- )
- }
- }
- }
- getScrollViewContent(dialog).apply {
- minimumHeight =
- resources.getDimensionPixelSize(initialUiProperties.scrollViewMinHeightResId)
- layoutParams.height = maxOf(cachedContentHeight, minimumHeight)
- }
+ contentManager =
+ bluetoothDetailsContentManagerFactory.create(
+ initialUiProperties,
+ cachedContentHeight,
+ bluetoothTileDialogCallback,
+ /* isInDialog= */ true,
+ /* doneButtonCallback= */ fun() {
+ dialog.dismiss()
+ },
+ )
+ contentManager.bind(dialog.requireViewById(R.id.root))
}
override fun onStart(dialog: SystemUIDialog) {
- lastUiUpdateMs = systemClock.elapsedRealtime()
+ contentManager.start()
}
override fun onStop(dialog: SystemUIDialog) {
- mutableContentHeight.tryEmit(getScrollViewContent(dialog).measuredHeight)
- }
-
- internal suspend fun animateProgressBar(dialog: SystemUIDialog, animate: Boolean) {
- withContext(mainDispatcher) {
- if (animate) {
- showProgressBar(dialog)
- } else {
- delay(PROGRESS_BAR_ANIMATION_DURATION_MS)
- hideProgressBar(dialog)
- }
- }
- }
-
- internal suspend fun onDeviceItemUpdated(
- dialog: SystemUIDialog,
- deviceItem: List<DeviceItem>,
- showSeeAll: Boolean,
- showPairNewDevice: Boolean,
- ) {
- withContext(mainDispatcher) {
- val start = systemClock.elapsedRealtime()
- val itemRow = deviceItem.size + showSeeAll.toInt() + showPairNewDevice.toInt()
- // If not the first load, add a slight delay for smoother dialog height change
- if (itemRow != lastItemRow && lastItemRow != -1) {
- delay(MIN_HEIGHT_CHANGE_INTERVAL_MS - (start - lastUiUpdateMs))
- }
- if (isActive) {
- deviceItemAdapter.refreshDeviceItemList(deviceItem) {
- getSeeAllButton(dialog).visibility = if (showSeeAll) VISIBLE else GONE
- getPairNewDeviceButton(dialog).visibility =
- if (showPairNewDevice) VISIBLE else GONE
- // Update the height after data is updated
- getScrollViewContent(dialog).layoutParams.height = WRAP_CONTENT
- lastUiUpdateMs = systemClock.elapsedRealtime()
- lastItemRow = itemRow
- logger.logDeviceUiUpdate(lastUiUpdateMs - start)
- }
- }
- }
- }
-
- internal fun onBluetoothStateUpdated(
- dialog: SystemUIDialog,
- isEnabled: Boolean,
- uiProperties: BluetoothTileDialogViewModel.UiProperties,
- ) {
- getToggleView(dialog).apply {
- isChecked = isEnabled
- setEnabled(true)
- alpha = ENABLED_ALPHA
- }
- getSubtitleTextView(dialog).text = dialog.context.getString(uiProperties.subTitleResId)
- getAutoOnToggleView(dialog).visibility = uiProperties.autoOnToggleVisibility
- }
-
- internal fun onBluetoothAutoOnUpdated(
- dialog: SystemUIDialog,
- isEnabled: Boolean,
- @StringRes infoResId: Int,
- ) {
- getAutoOnToggle(dialog).isChecked = isEnabled
- getAutoOnToggleInfoTextView(dialog).text = dialog.context.getString(infoResId)
- }
-
- internal fun onAudioSharingButtonUpdated(
- dialog: SystemUIDialog,
- visibility: Int,
- label: String?,
- isActive: Boolean,
- ) {
- getAudioSharingButtonView(dialog).apply {
- this.visibility = visibility
- label?.let { text = it }
- this.isActivated = isActive
- }
- }
-
- private fun setupToggle(dialog: SystemUIDialog) {
- val toggleView = getToggleView(dialog)
- toggleView.setOnCheckedChangeListener { view, isChecked ->
- mutableBluetoothStateToggle.value = isChecked
- view.apply {
- isEnabled = false
- alpha = DISABLED_ALPHA
- }
- logger.logBluetoothState(BluetoothStateStage.USER_TOGGLED, isChecked.toString())
- uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_TOGGLE_CLICKED)
- }
-
- getAutoOnToggleView(dialog).visibility = initialUiProperties.autoOnToggleVisibility
- getAutoOnToggle(dialog).setOnCheckedChangeListener { _, isChecked ->
- mutableBluetoothAutoOnToggle.value = isChecked
- uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_AUTO_ON_TOGGLE_CLICKED)
- }
- }
-
- private fun getToggleView(dialog: SystemUIDialog): Switch {
- return dialog.requireViewById(R.id.bluetooth_toggle)
- }
-
- private fun getSubtitleTextView(dialog: SystemUIDialog): TextView {
- return dialog.requireViewById(R.id.bluetooth_tile_dialog_subtitle)
- }
-
- private fun getSeeAllButton(dialog: SystemUIDialog): View {
- return dialog.requireViewById(R.id.see_all_button)
- }
-
- private fun getPairNewDeviceButton(dialog: SystemUIDialog): View {
- return dialog.requireViewById(R.id.pair_new_device_button)
- }
-
- private fun getDeviceListView(dialog: SystemUIDialog): RecyclerView {
- return dialog.requireViewById(R.id.device_list)
- }
-
- private fun getAutoOnToggle(dialog: SystemUIDialog): Switch {
- return dialog.requireViewById(R.id.bluetooth_auto_on_toggle)
- }
-
- private fun getAudioSharingButtonView(dialog: SystemUIDialog): Button {
- return dialog.requireViewById(R.id.audio_sharing_button)
- }
-
- private fun getAutoOnToggleView(dialog: SystemUIDialog): View {
- return dialog.requireViewById(R.id.bluetooth_auto_on_toggle_layout)
- }
-
- private fun getAutoOnToggleInfoTextView(dialog: SystemUIDialog): TextView {
- return dialog.requireViewById(R.id.bluetooth_auto_on_toggle_info_text)
- }
-
- private fun getProgressBarAnimation(dialog: SystemUIDialog): ProgressBar {
- return dialog.requireViewById(R.id.bluetooth_tile_dialog_progress_animation)
- }
-
- private fun getProgressBarBackground(dialog: SystemUIDialog): View {
- return dialog.requireViewById(R.id.bluetooth_tile_dialog_progress_background)
- }
-
- private fun getScrollViewContent(dialog: SystemUIDialog): View {
- return dialog.requireViewById(R.id.scroll_view)
- }
-
- private fun setupRecyclerView(dialog: SystemUIDialog) {
- getDeviceListView(dialog).apply {
- layoutManager = LinearLayoutManager(dialog.context)
- adapter = deviceItemAdapter
- }
- }
-
- private fun showProgressBar(dialog: SystemUIDialog) {
- val progressBarAnimation = getProgressBarAnimation(dialog)
- val progressBarBackground = getProgressBarBackground(dialog)
- if (progressBarAnimation.visibility != VISIBLE) {
- progressBarAnimation.visibility = VISIBLE
- progressBarBackground.visibility = INVISIBLE
- }
- }
-
- private fun hideProgressBar(dialog: SystemUIDialog) {
- val progressBarAnimation = getProgressBarAnimation(dialog)
- val progressBarBackground = getProgressBarBackground(dialog)
- if (progressBarAnimation.visibility != INVISIBLE) {
- progressBarAnimation.visibility = INVISIBLE
- progressBarBackground.visibility = VISIBLE
- }
- }
-
- internal inner class Adapter : RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() {
-
- private val diffUtilCallback =
- object : DiffUtil.ItemCallback<DeviceItem>() {
- override fun areItemsTheSame(
- deviceItem1: DeviceItem,
- deviceItem2: DeviceItem,
- ): Boolean {
- return deviceItem1.cachedBluetoothDevice == deviceItem2.cachedBluetoothDevice
- }
-
- override fun areContentsTheSame(
- deviceItem1: DeviceItem,
- deviceItem2: DeviceItem,
- ): Boolean {
- return deviceItem1.type == deviceItem2.type &&
- deviceItem1.cachedBluetoothDevice == deviceItem2.cachedBluetoothDevice &&
- deviceItem1.deviceName == deviceItem2.deviceName &&
- deviceItem1.connectionSummary == deviceItem2.connectionSummary &&
- // Ignored the icon drawable
- deviceItem1.iconWithDescription?.second ==
- deviceItem2.iconWithDescription?.second &&
- deviceItem1.background == deviceItem2.background &&
- deviceItem1.isEnabled == deviceItem2.isEnabled &&
- deviceItem1.actionAccessibilityLabel == deviceItem2.actionAccessibilityLabel
- }
- }
-
- private val asyncListDiffer = AsyncListDiffer(this, diffUtilCallback)
-
- 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() = asyncListDiffer.currentList.size
-
- override fun onBindViewHolder(holder: DeviceItemViewHolder, position: Int) {
- val item = getItem(position)
- holder.bind(item)
- }
-
- internal fun getItem(position: Int) = asyncListDiffer.currentList[position]
-
- internal fun refreshDeviceItemList(updated: List<DeviceItem>, callback: () -> Unit) {
- asyncListDiffer.submitList(updated, callback)
- }
-
- internal inner class DeviceItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
- private val container = view.requireViewById<View>(R.id.bluetooth_device_row)
- 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)
- private val actionIcon = view.requireViewById<ImageView>(R.id.gear_icon_image)
- private val actionIconView = view.requireViewById<View>(R.id.gear_icon)
- private val divider = view.requireViewById<View>(R.id.divider)
-
- internal fun bind(item: DeviceItem) {
- container.apply {
- isEnabled = item.isEnabled
- background = item.background?.let { context.getDrawable(it) }
- setOnClickListener {
- mutableDeviceItemClick.tryEmit(
- DeviceItemClick(item, it, DeviceItemClick.Target.ENTIRE_ROW)
- )
- uiEventLogger.log(BluetoothTileDialogUiEvent.DEVICE_CLICKED)
- }
-
- // updating icon colors
- val tintColor =
- context.getColor(
- if (item.isActive) InternalR.color.materialColorOnPrimaryContainer
- else InternalR.color.materialColorOnSurface
- )
-
- // update icons
- iconView.apply {
- item.iconWithDescription?.let {
- setImageDrawable(it.first)
- contentDescription = it.second
- }
- }
-
- actionIcon.setImageResource(item.actionIconRes)
- actionIcon.drawable?.setTint(tintColor)
-
- divider.setBackgroundColor(tintColor)
-
- // update text styles
- nameView.setTextAppearance(
- if (item.isActive) R.style.TextAppearance_BluetoothTileDialog_Active
- else R.style.TextAppearance_BluetoothTileDialog
- )
- summaryView.setTextAppearance(
- if (item.isActive) R.style.TextAppearance_BluetoothTileDialog_Active
- else R.style.TextAppearance_BluetoothTileDialog
- )
-
- accessibilityDelegate =
- object : AccessibilityDelegate() {
- override fun onInitializeAccessibilityNodeInfo(
- host: View,
- info: AccessibilityNodeInfo,
- ) {
- super.onInitializeAccessibilityNodeInfo(host, info)
- info.addAction(
- AccessibilityAction(
- AccessibilityAction.ACTION_CLICK.id,
- item.actionAccessibilityLabel,
- )
- )
- }
- }
- }
- nameView.text = item.deviceName
- summaryView.text = item.connectionSummary
-
- actionIconView.setOnClickListener {
- mutableDeviceItemClick.tryEmit(
- DeviceItemClick(item, it, DeviceItemClick.Target.ACTION_ICON)
- )
- }
- }
- }
- }
-
- internal companion object {
- const val MIN_HEIGHT_CHANGE_INTERVAL_MS = 800L
- const val ACTION_BLUETOOTH_DEVICE_DETAILS =
- "com.android.settings.BLUETOOTH_DEVICE_DETAIL_SETTINGS"
- const val ACTION_PREVIOUSLY_CONNECTED_DEVICE =
- "com.android.settings.PREVIOUSLY_CONNECTED_DEVICE"
- const val ACTION_PAIR_NEW_DEVICE = "android.settings.BLUETOOTH_PAIRING_SETTINGS"
- const val ACTION_AUDIO_SHARING = "com.android.settings.BLUETOOTH_AUDIO_SHARING_SETTINGS"
- const val DISABLED_ALPHA = 0.3f
- const val ENABLED_ALPHA = 1f
- const val PROGRESS_BAR_ANIMATION_DURATION_MS = 1500L
-
- private fun Boolean.toInt(): Int {
- return if (this) 1 else 0
- }
+ contentManager.releaseView()
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModel.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModel.kt
index bf04897..9492abb 100644
--- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModel.kt
@@ -16,6 +16,7 @@
package com.android.systemui.bluetooth.qsdialog
+import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
@@ -34,15 +35,16 @@
import com.android.systemui.animation.DialogCuj
import com.android.systemui.animation.DialogTransitionAnimator
import com.android.systemui.animation.Expandable
-import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogDelegate.Companion.ACTION_AUDIO_SHARING
-import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogDelegate.Companion.ACTION_PAIR_NEW_DEVICE
-import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogDelegate.Companion.ACTION_PREVIOUSLY_CONNECTED_DEVICE
+import com.android.systemui.bluetooth.qsdialog.BluetoothDetailsContentManager.Companion.ACTION_AUDIO_SHARING
+import com.android.systemui.bluetooth.qsdialog.BluetoothDetailsContentManager.Companion.ACTION_PAIR_NEW_DEVICE
+import com.android.systemui.bluetooth.qsdialog.BluetoothDetailsContentManager.Companion.ACTION_PREVIOUSLY_CONNECTED_DEVICE
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.res.R
+import com.android.systemui.statusbar.phone.SystemUIDialog
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -57,7 +59,12 @@
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
-/** ViewModel for Bluetooth Dialog after clicking on the Bluetooth QS tile. */
+/**
+ * ViewModel for Bluetooth Dialog or Bluetooth Details View after clicking on the Bluetooth QS tile.
+ *
+ * TODO: b/378513956 Rename this class to BluetoothDetailsContentViewModel, since it's not only used
+ * by the dialog view.
+ */
@SysUISingleton
internal class BluetoothTileDialogViewModel
@Inject
@@ -78,36 +85,61 @@
@Background private val backgroundDispatcher: CoroutineDispatcher,
@Main private val sharedPreferences: SharedPreferences,
private val bluetoothDialogDelegateFactory: BluetoothTileDialogDelegate.Factory,
+ private val bluetoothDetailsContentManagerFactory: BluetoothDetailsContentManager.Factory,
) : BluetoothTileDialogCallback {
+ lateinit var contentManager: BluetoothDetailsContentManager
private var job: Job? = null
/**
- * Shows the dialog.
+ * Shows the details content.
*
- * @param view The view from which the dialog is shown.
+ * @param view The view from which the dialog is shown. If view is null, it should show the
+ * bluetooth tile details view.
+ *
+ * TODO: b/378513956 Refactor this method into 2. One is called by the dialog to show the
+ * dialog, another is called by the details view model to bind the view.
*/
- fun showDialog(expandable: Expandable?) {
+ fun showDetailsContent(expandable: Expandable?, view: View?) {
cancelJob()
job =
coroutineScope.launch(context = mainDispatcher) {
var updateDeviceItemJob: Job?
var updateDialogUiJob: Job? = null
- val dialogDelegate = createBluetoothTileDialog()
- val dialog = dialogDelegate.createDialog()
- val context = dialog.context
+ val dialog: SystemUIDialog?
+ val context: Context
- val controller =
- expandable?.dialogTransitionController(
- DialogCuj(
- InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
- INTERACTION_JANK_TAG,
+ if (view == null) {
+ // Render with dialog
+ val dialogDelegate = createBluetoothTileDialog()
+ dialog = dialogDelegate.createDialog()
+ context = dialog.context
+
+ val controller =
+ expandable?.dialogTransitionController(
+ DialogCuj(
+ InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN,
+ INTERACTION_JANK_TAG,
+ )
)
- )
- controller?.let {
- dialogTransitionAnimator.show(dialog, it, animateBackgroundBoundsChange = true)
- } ?: dialog.show()
+ controller?.let {
+ dialogTransitionAnimator.show(
+ dialog,
+ it,
+ animateBackgroundBoundsChange = true,
+ )
+ } ?: dialog.show()
+ // contentManager is created after dialog.show
+ contentManager = dialogDelegate.contentManager
+ } else {
+ // Render with tile details view
+ dialog = null
+ context = view.context
+ contentManager = createContentManager()
+ contentManager.bind(view)
+ contentManager.start()
+ }
updateDeviceItemJob = launch {
deviceItemInteractor.updateDeviceItems(context, DeviceFetchTrigger.FIRST_LOAD)
@@ -121,15 +153,14 @@
) { deviceItem, showSeeAll ->
updateDialogUiJob?.cancel()
updateDialogUiJob = launch {
- dialogDelegate.apply {
+ contentManager.apply {
onDeviceItemUpdated(
- dialog,
deviceItem,
showSeeAll,
showPairNewDevice =
bluetoothStateInteractor.isBluetoothEnabled(),
)
- animateProgressBar(dialog, false)
+ animateProgressBar(false)
}
}
}
@@ -150,7 +181,7 @@
},
)
.onEach {
- dialogDelegate.animateProgressBar(dialog, true)
+ contentManager.animateProgressBar(true)
updateDeviceItemJob?.cancel()
updateDeviceItemJob = launch {
deviceItemInteractor.updateDeviceItems(
@@ -171,16 +202,14 @@
.onEach {
when (it) {
is AudioSharingButtonState.Visible -> {
- dialogDelegate.onAudioSharingButtonUpdated(
- dialog,
+ contentManager.onAudioSharingButtonUpdated(
VISIBLE,
context.getString(it.resId),
it.isActive,
)
}
is AudioSharingButtonState.Gone -> {
- dialogDelegate.onAudioSharingButtonUpdated(
- dialog,
+ contentManager.onAudioSharingButtonUpdated(
GONE,
label = null,
isActive = false,
@@ -197,8 +226,7 @@
// the device item list.
bluetoothStateInteractor.bluetoothStateUpdate
.onEach {
- dialogDelegate.onBluetoothStateUpdated(
- dialog,
+ contentManager.onBluetoothStateUpdated(
it,
UiProperties.build(it, isAutoOnToggleFeatureAvailable()),
)
@@ -214,16 +242,17 @@
// bluetoothStateToggle is emitted when user toggles the bluetooth state switch,
// send the new value to the bluetoothStateInteractor and animate the progress bar.
- dialogDelegate.bluetoothStateToggle
+ contentManager.bluetoothStateToggle
.filterNotNull()
.onEach {
- dialogDelegate.animateProgressBar(dialog, true)
+ contentManager.animateProgressBar(true)
bluetoothStateInteractor.setBluetoothEnabled(it)
}
.launchIn(this)
// deviceItemClick is emitted when user clicked on a device item.
- dialogDelegate.deviceItemClick
+ contentManager.deviceItemClick
+ .filterNotNull()
.onEach {
when (it.target) {
DeviceItemClick.Target.ENTIRE_ROW -> {
@@ -245,7 +274,8 @@
.launchIn(this)
// contentHeight is emitted when the dialog is dismissed.
- dialogDelegate.contentHeight
+ contentManager.contentHeight
+ .filterNotNull()
.onEach {
withContext(backgroundDispatcher) {
sharedPreferences.edit().putInt(CONTENT_HEIGHT_PREF_KEY, it).apply()
@@ -258,8 +288,7 @@
// changed.
bluetoothAutoOnInteractor.isEnabled
.onEach {
- dialogDelegate.onBluetoothAutoOnUpdated(
- dialog,
+ contentManager.onBluetoothAutoOnUpdated(
it,
if (it) R.string.turn_on_bluetooth_auto_info_enabled
else R.string.turn_on_bluetooth_auto_info_disabled,
@@ -269,36 +298,48 @@
// bluetoothAutoOnToggle is emitted when user toggles the bluetooth auto on
// switch, send the new value to the bluetoothAutoOnInteractor.
- dialogDelegate.bluetoothAutoOnToggle
+ contentManager.bluetoothAutoOnToggle
.filterNotNull()
.onEach { bluetoothAutoOnInteractor.setEnabled(it) }
.launchIn(this)
}
- produce<Unit> { awaitClose { dialog.cancel() } }
+ produce<Unit> { awaitClose { dialog?.cancel() } }
}
}
private suspend fun createBluetoothTileDialog(): BluetoothTileDialogDelegate {
- val cachedContentHeight =
- withContext(backgroundDispatcher) {
- sharedPreferences.getInt(
- CONTENT_HEIGHT_PREF_KEY,
- ViewGroup.LayoutParams.WRAP_CONTENT,
- )
- }
-
return bluetoothDialogDelegateFactory.create(
- UiProperties.build(
- bluetoothStateInteractor.isBluetoothEnabled(),
- isAutoOnToggleFeatureAvailable(),
- ),
- cachedContentHeight,
+ getUiProperties(),
+ getCachedContentHeight(),
this@BluetoothTileDialogViewModel,
{ cancelJob() },
)
}
+ private suspend fun createContentManager(): BluetoothDetailsContentManager {
+ return bluetoothDetailsContentManagerFactory.create(
+ getUiProperties(),
+ getCachedContentHeight(),
+ this@BluetoothTileDialogViewModel,
+ /* isInDialog= */ false,
+ /* doneButtonCallback= */ fun() {},
+ )
+ }
+
+ private suspend fun getUiProperties(): UiProperties {
+ return UiProperties.build(
+ bluetoothStateInteractor.isBluetoothEnabled(),
+ isAutoOnToggleFeatureAvailable(),
+ )
+ }
+
+ private suspend fun getCachedContentHeight(): Int {
+ return withContext(backgroundDispatcher) {
+ sharedPreferences.getInt(CONTENT_HEIGHT_PREF_KEY, ViewGroup.LayoutParams.WRAP_CONTENT)
+ }
+ }
+
override fun onSeeAllClicked(view: View) {
uiEventLogger.log(BluetoothTileDialogUiEvent.SEE_ALL_CLICKED)
startSettingsActivity(Intent(ACTION_PREVIOUSLY_CONNECTED_DEVICE), view)
diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/DeviceItemActionInteractor.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/DeviceItemActionInteractor.kt
index cb4ec37..26996ac 100644
--- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/DeviceItemActionInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/DeviceItemActionInteractor.kt
@@ -27,7 +27,7 @@
import kotlinx.coroutines.withContext
interface DeviceItemActionInteractor {
- suspend fun onClick(deviceItem: DeviceItem, dialog: SystemUIDialog)
+ suspend fun onClick(deviceItem: DeviceItem, dialog: SystemUIDialog?) {}
suspend fun onActionIconClick(deviceItem: DeviceItem, onIntent: (Intent) -> Unit)
}
@@ -40,7 +40,7 @@
private val uiEventLogger: UiEventLogger,
) : DeviceItemActionInteractor {
- override suspend fun onClick(deviceItem: DeviceItem, dialog: SystemUIDialog) {
+ override suspend fun onClick(deviceItem: DeviceItem, dialog: SystemUIDialog?) {
withContext(backgroundDispatcher) {
deviceItem.cachedBluetoothDevice.apply {
when (deviceItem.type) {
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 0109e70a..1cfa663 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java
@@ -158,7 +158,7 @@
private void handleClickEvent(@Nullable Expandable expandable) {
if (mFeatureFlags.isEnabled(Flags.BLUETOOTH_QS_TILE_DIALOG)) {
- mDialogViewModel.showDialog(expandable);
+ mDialogViewModel.showDetailsContent(expandable, /* view= */ null);
} else {
// Secondary clicks are header clicks, just toggle.
toggleBluetooth();
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManagerTest.kt
new file mode 100644
index 0000000..6ed990d
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManagerTest.kt
@@ -0,0 +1,461 @@
+/*
+ * 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.systemui.bluetooth.qsdialog
+
+import android.graphics.drawable.Drawable
+import android.testing.TestableLooper
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.internal.logging.UiEventLogger
+import com.android.settingslib.bluetooth.CachedBluetoothDevice
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.animation.DialogTransitionAnimator
+import com.android.systemui.coroutines.collectLastValue
+import com.android.systemui.kosmos.testDispatcher
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.model.SysUiState
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.phone.SystemUIDialog
+import com.android.systemui.statusbar.phone.SystemUIDialogManager
+import com.android.systemui.testKosmos
+import com.android.systemui.util.time.FakeSystemClock
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runCurrent
+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.ArgumentMatchers.anyBoolean
+import org.mockito.ArgumentMatchers.anyLong
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class BluetoothDetailsContentManagerTest : SysuiTestCase() {
+ companion object {
+ const val DEVICE_NAME = "device"
+ const val DEVICE_CONNECTION_SUMMARY = "active"
+ const val ENABLED = true
+ const val CONTENT_HEIGHT = WRAP_CONTENT
+ }
+
+ @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ private val cachedBluetoothDevice = mock<CachedBluetoothDevice>()
+
+ private val bluetoothTileDialogCallback = mock<BluetoothTileDialogCallback>()
+
+ private val drawable = mock<Drawable>()
+
+ private val uiEventLogger = mock<UiEventLogger>()
+
+ private val logger = mock<BluetoothTileDialogLogger>()
+
+ private val sysuiDialogFactory = mock<SystemUIDialog.Factory>()
+ private val dialogManager = mock<SystemUIDialogManager>()
+ private val sysuiState = mock<SysUiState>()
+ private val dialogTransitionAnimator = mock<DialogTransitionAnimator>()
+
+ private val fakeSystemClock = FakeSystemClock()
+
+ private val uiProperties =
+ BluetoothTileDialogViewModel.UiProperties.build(
+ isBluetoothEnabled = ENABLED,
+ isAutoOnToggleFeatureAvailable = ENABLED,
+ )
+
+ private lateinit var icon: Pair<Drawable, String>
+ private lateinit var mBluetoothDetailsContentManager: BluetoothDetailsContentManager
+ private lateinit var deviceItem: DeviceItem
+ private lateinit var contentView: View
+
+ private val kosmos = testKosmos()
+
+ @Before
+ fun setUp() {
+ with(kosmos) {
+ contentView =
+ LayoutInflater.from(mContext).inflate(R.layout.bluetooth_tile_dialog, null)
+
+ whenever(sysuiState.setFlag(anyLong(), anyBoolean())).thenReturn(sysuiState)
+
+ mBluetoothDetailsContentManager =
+ BluetoothDetailsContentManager(
+ uiProperties,
+ CONTENT_HEIGHT,
+ bluetoothTileDialogCallback,
+ /* isInDialog= */ true,
+ {},
+ testDispatcher,
+ fakeSystemClock,
+ uiEventLogger,
+ logger,
+ )
+
+ whenever(sysuiDialogFactory.create(any<SystemUIDialog.Delegate>(), any())).thenAnswer {
+ SystemUIDialog(
+ mContext,
+ 0,
+ SystemUIDialog.DEFAULT_DISMISS_ON_DEVICE_LOCK,
+ dialogManager,
+ sysuiState,
+ fakeBroadcastDispatcher,
+ dialogTransitionAnimator,
+ it.getArgument(0),
+ )
+ }
+
+ 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,
+ )
+ whenever(cachedBluetoothDevice.isBusy).thenReturn(false)
+ }
+ }
+
+ @Test
+ fun testShowDialog_createRecyclerViewWithAdapter() {
+ mBluetoothDetailsContentManager.bind(contentView)
+ mBluetoothDetailsContentManager.start()
+
+ val recyclerView = contentView.requireViewById<RecyclerView>(R.id.device_list)
+
+ assertThat(recyclerView).isNotNull()
+ assertThat(recyclerView.visibility).isEqualTo(VISIBLE)
+ assertThat(recyclerView.adapter).isNotNull()
+ assertThat(recyclerView.layoutManager is LinearLayoutManager).isTrue()
+ mBluetoothDetailsContentManager.releaseView()
+ }
+
+ @Test
+ fun testShowDialog_displayBluetoothDevice() {
+ with(kosmos) {
+ testScope.runTest {
+ mBluetoothDetailsContentManager.bind(contentView)
+ mBluetoothDetailsContentManager.start()
+ fakeSystemClock.setElapsedRealtime(Long.MAX_VALUE)
+ mBluetoothDetailsContentManager.onDeviceItemUpdated(
+ listOf(deviceItem),
+ showSeeAll = false,
+ showPairNewDevice = false,
+ )
+
+ val recyclerView = contentView.requireViewById<RecyclerView>(R.id.device_list)
+ val adapter = recyclerView?.adapter as BluetoothDetailsContentManager.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)
+ mBluetoothDetailsContentManager.releaseView()
+ }
+ }
+ }
+
+ @Test
+ fun testDeviceItemViewHolder_cachedDeviceNotBusy() {
+ with(kosmos) {
+ testScope.runTest {
+ deviceItem.isEnabled = true
+
+ val view =
+ LayoutInflater.from(mContext)
+ .inflate(R.layout.bluetooth_device_item, null, false)
+ val viewHolder =
+ mBluetoothDetailsContentManager.Adapter().DeviceItemViewHolder(view)
+ viewHolder.bind(deviceItem)
+ val container = view.requireViewById<View>(R.id.bluetooth_device_row)
+
+ assertThat(container).isNotNull()
+ assertThat(container.isEnabled).isTrue()
+ assertThat(container.hasOnClickListeners()).isTrue()
+ val value by collectLastValue(mBluetoothDetailsContentManager.deviceItemClick)
+ runCurrent()
+ container.performClick()
+ runCurrent()
+ assertThat(value).isNotNull()
+ value?.let {
+ assertThat(it.target).isEqualTo(DeviceItemClick.Target.ENTIRE_ROW)
+ assertThat(it.clickedView).isEqualTo(container)
+ assertThat(it.deviceItem).isEqualTo(deviceItem)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun testDeviceItemViewHolder_cachedDeviceBusy() {
+ with(kosmos) {
+ deviceItem.isEnabled = false
+
+ val view =
+ LayoutInflater.from(mContext).inflate(R.layout.bluetooth_device_item, null, false)
+ val viewHolder =
+ BluetoothDetailsContentManager(
+ uiProperties,
+ CONTENT_HEIGHT,
+ bluetoothTileDialogCallback,
+ /* isInDialog= */ true,
+ {},
+ testDispatcher,
+ fakeSystemClock,
+ uiEventLogger,
+ logger,
+ )
+ .Adapter()
+ .DeviceItemViewHolder(view)
+ viewHolder.bind(deviceItem)
+ val container = view.requireViewById<View>(R.id.bluetooth_device_row)
+
+ assertThat(container).isNotNull()
+ assertThat(container.isEnabled).isFalse()
+ assertThat(container.hasOnClickListeners()).isTrue()
+ }
+ }
+
+ @Test
+ fun testDeviceItemViewHolder_clickActionIcon() {
+ with(kosmos) {
+ testScope.runTest {
+ deviceItem.isEnabled = true
+
+ val view =
+ LayoutInflater.from(mContext)
+ .inflate(R.layout.bluetooth_device_item, null, false)
+ val viewHolder =
+ mBluetoothDetailsContentManager.Adapter().DeviceItemViewHolder(view)
+ viewHolder.bind(deviceItem)
+ val actionIconView = view.requireViewById<View>(R.id.gear_icon)
+
+ assertThat(actionIconView).isNotNull()
+ assertThat(actionIconView.hasOnClickListeners()).isTrue()
+ val value by collectLastValue(mBluetoothDetailsContentManager.deviceItemClick)
+ runCurrent()
+ actionIconView.performClick()
+ runCurrent()
+ assertThat(value).isNotNull()
+ value?.let {
+ assertThat(it.target).isEqualTo(DeviceItemClick.Target.ACTION_ICON)
+ assertThat(it.clickedView).isEqualTo(actionIconView)
+ assertThat(it.deviceItem).isEqualTo(deviceItem)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun testOnDeviceUpdated_hideSeeAll_showPairNew() {
+ with(kosmos) {
+ testScope.runTest {
+ mBluetoothDetailsContentManager.bind(contentView)
+ mBluetoothDetailsContentManager.start()
+ fakeSystemClock.setElapsedRealtime(Long.MAX_VALUE)
+ mBluetoothDetailsContentManager.onDeviceItemUpdated(
+ listOf(deviceItem),
+ showSeeAll = false,
+ showPairNewDevice = true,
+ )
+
+ val seeAllButton = contentView.requireViewById<View>(R.id.see_all_button)
+ val pairNewButton = contentView.requireViewById<View>(R.id.pair_new_device_button)
+ val recyclerView = contentView.requireViewById<RecyclerView>(R.id.device_list)
+ val adapter = recyclerView?.adapter as BluetoothDetailsContentManager.Adapter
+ val scrollViewContent = contentView.requireViewById<View>(R.id.scroll_view)
+
+ assertThat(seeAllButton).isNotNull()
+ assertThat(seeAllButton.visibility).isEqualTo(GONE)
+ assertThat(pairNewButton).isNotNull()
+ assertThat(pairNewButton.visibility).isEqualTo(VISIBLE)
+ assertThat(adapter.itemCount).isEqualTo(1)
+ assertThat(scrollViewContent.layoutParams.height).isEqualTo(WRAP_CONTENT)
+ mBluetoothDetailsContentManager.releaseView()
+ }
+ }
+ }
+
+ @Test
+ fun testShowDialog_cachedHeightLargerThanMinHeight_displayFromCachedHeight() {
+ with(kosmos) {
+ testScope.runTest {
+ val cachedHeight = Int.MAX_VALUE
+ val contentManager =
+ BluetoothDetailsContentManager(
+ BluetoothTileDialogViewModel.UiProperties.build(ENABLED, ENABLED),
+ cachedHeight,
+ bluetoothTileDialogCallback,
+ /* isInDialog= */ true,
+ {},
+ testDispatcher,
+ fakeSystemClock,
+ uiEventLogger,
+ logger,
+ )
+ contentManager.bind(contentView)
+ contentManager.start()
+ assertThat(contentView.requireViewById<View>(R.id.scroll_view).layoutParams.height)
+ .isEqualTo(cachedHeight)
+ contentManager.releaseView()
+ }
+ }
+ }
+
+ @Test
+ fun testShowDialog_cachedHeightLessThanMinHeight_displayFromUiProperties() {
+ with(kosmos) {
+ testScope.runTest {
+ val contentManager =
+ BluetoothDetailsContentManager(
+ BluetoothTileDialogViewModel.UiProperties.build(ENABLED, ENABLED),
+ MATCH_PARENT,
+ bluetoothTileDialogCallback,
+ /* isInDialog= */ true,
+ {},
+ testDispatcher,
+ fakeSystemClock,
+ uiEventLogger,
+ logger,
+ )
+ contentManager.bind(contentView)
+ contentManager.start()
+ assertThat(contentView.requireViewById<View>(R.id.scroll_view).layoutParams.height)
+ .isGreaterThan(MATCH_PARENT)
+ contentManager.releaseView()
+ }
+ }
+ }
+
+ @Test
+ fun testShowDialog_bluetoothEnabled_autoOnToggleGone() {
+ with(kosmos) {
+ testScope.runTest {
+ val contentManager =
+ BluetoothDetailsContentManager(
+ BluetoothTileDialogViewModel.UiProperties.build(ENABLED, ENABLED),
+ MATCH_PARENT,
+ bluetoothTileDialogCallback,
+ /* isInDialog= */ true,
+ {},
+ testDispatcher,
+ fakeSystemClock,
+ uiEventLogger,
+ logger,
+ )
+ contentManager.bind(contentView)
+ contentManager.start()
+ assertThat(
+ contentView
+ .requireViewById<View>(R.id.bluetooth_auto_on_toggle_layout)
+ .visibility
+ )
+ .isEqualTo(GONE)
+ contentManager.releaseView()
+ }
+ }
+ }
+
+ @Test
+ fun testOnAudioSharingButtonUpdated_visibleActive_activateButton() {
+ with(kosmos) {
+ testScope.runTest {
+ mBluetoothDetailsContentManager.bind(contentView)
+ mBluetoothDetailsContentManager.start()
+ fakeSystemClock.setElapsedRealtime(Long.MAX_VALUE)
+ mBluetoothDetailsContentManager.onAudioSharingButtonUpdated(
+ visibility = VISIBLE,
+ label = null,
+ isActive = true,
+ )
+
+ val audioSharingButton =
+ contentView.requireViewById<View>(R.id.audio_sharing_button)
+
+ assertThat(audioSharingButton).isNotNull()
+ assertThat(audioSharingButton.visibility).isEqualTo(VISIBLE)
+ assertThat(audioSharingButton.isActivated).isTrue()
+ mBluetoothDetailsContentManager.releaseView()
+ }
+ }
+ }
+
+ @Test
+ fun testOnAudioSharingButtonUpdated_visibleNotActive_inactivateButton() {
+ with(kosmos) {
+ testScope.runTest {
+ mBluetoothDetailsContentManager.bind(contentView)
+ mBluetoothDetailsContentManager.start()
+ fakeSystemClock.setElapsedRealtime(Long.MAX_VALUE)
+ mBluetoothDetailsContentManager.onAudioSharingButtonUpdated(
+ visibility = VISIBLE,
+ label = null,
+ isActive = false,
+ )
+
+ val audioSharingButton =
+ contentView.requireViewById<View>(R.id.audio_sharing_button)
+
+ assertThat(audioSharingButton).isNotNull()
+ assertThat(audioSharingButton.visibility).isEqualTo(VISIBLE)
+ assertThat(audioSharingButton.isActivated).isFalse()
+ mBluetoothDetailsContentManager.releaseView()
+ }
+ }
+ }
+
+ @Test
+ fun testOnAudioSharingButtonUpdated_gone_inactivateButton() {
+ with(kosmos) {
+ testScope.runTest {
+ mBluetoothDetailsContentManager.bind(contentView)
+ mBluetoothDetailsContentManager.start()
+ fakeSystemClock.setElapsedRealtime(Long.MAX_VALUE)
+ mBluetoothDetailsContentManager.onAudioSharingButtonUpdated(
+ visibility = GONE,
+ label = null,
+ isActive = false,
+ )
+
+ val audioSharingButton =
+ contentView.requireViewById<View>(R.id.audio_sharing_button)
+
+ assertThat(audioSharingButton).isNotNull()
+ assertThat(audioSharingButton.visibility).isEqualTo(GONE)
+ assertThat(audioSharingButton.isActivated).isFalse()
+ mBluetoothDetailsContentManager.releaseView()
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegateTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegateTest.kt
index 4396b0a..ffc7518 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegateTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegateTest.kt
@@ -16,47 +16,34 @@
package com.android.systemui.bluetooth.qsdialog
-import android.graphics.drawable.Drawable
import android.testing.TestableLooper
-import android.view.LayoutInflater
-import android.view.View
-import android.view.View.GONE
-import android.view.View.VISIBLE
-import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.logging.UiEventLogger
-import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.systemui.SysuiTestCase
import com.android.systemui.animation.DialogTransitionAnimator
-import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testDispatcher
import com.android.systemui.kosmos.testScope
import com.android.systemui.model.SysUiState
-import com.android.systemui.res.R
import com.android.systemui.shade.data.repository.shadeDialogContextInteractor
import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.statusbar.phone.SystemUIDialogManager
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.whenever
-import com.android.systemui.util.time.FakeSystemClock
-import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runCurrent
-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.ArgumentMatchers.anyBoolean
+import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.Mock
-import org.mockito.Mockito.`when`
+import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
@@ -73,33 +60,31 @@
@get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
- @Mock private lateinit var cachedBluetoothDevice: CachedBluetoothDevice
+ @Mock
+ private lateinit var bluetoothDetailsContentManagerFactory:
+ BluetoothDetailsContentManager.Factory
+
+ @Mock private lateinit var bluetoothDetailsContentManager: BluetoothDetailsContentManager
@Mock private lateinit var bluetoothTileDialogCallback: BluetoothTileDialogCallback
- @Mock private lateinit var drawable: Drawable
-
@Mock private lateinit var uiEventLogger: UiEventLogger
- @Mock private lateinit var logger: BluetoothTileDialogLogger
+ @Mock private lateinit var sysuiDialogFactory: SystemUIDialog.Factory
+ @Mock private lateinit var dialogManager: SystemUIDialogManager
+ @Mock private lateinit var sysuiState: SysUiState
+ @Mock private lateinit var dialogTransitionAnimator: DialogTransitionAnimator
private val uiProperties =
BluetoothTileDialogViewModel.UiProperties.build(
isBluetoothEnabled = ENABLED,
isAutoOnToggleFeatureAvailable = ENABLED,
)
- @Mock private lateinit var sysuiDialogFactory: SystemUIDialog.Factory
- @Mock private lateinit var dialogManager: SystemUIDialogManager
- @Mock private lateinit var sysuiState: SysUiState
- @Mock private lateinit var dialogTransitionAnimator: DialogTransitionAnimator
- private val fakeSystemClock = FakeSystemClock()
-
+ private lateinit var scheduler: TestCoroutineScheduler
private lateinit var dispatcher: CoroutineDispatcher
private lateinit var testScope: TestScope
- private lateinit var icon: Pair<Drawable, String>
private lateinit var mBluetoothTileDialogDelegate: BluetoothTileDialogDelegate
- private lateinit var deviceItem: DeviceItem
private val kosmos = testKosmos()
@@ -116,12 +101,10 @@
CONTENT_HEIGHT,
bluetoothTileDialogCallback,
{},
- dispatcher,
- fakeSystemClock,
uiEventLogger,
- logger,
sysuiDialogFactory,
kosmos.shadeDialogContextInteractor,
+ bluetoothDetailsContentManagerFactory,
)
whenever(sysuiDialogFactory.create(any(SystemUIDialog.Delegate::class.java), any()))
@@ -138,17 +121,16 @@
)
}
- 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,
+ whenever(
+ bluetoothDetailsContentManagerFactory.create(
+ any(),
+ anyInt(),
+ any(),
+ anyBoolean(),
+ any(),
+ )
)
- `when`(cachedBluetoothDevice.isBusy).thenReturn(false)
+ .thenReturn(bluetoothDetailsContentManager)
}
@Test
@@ -156,287 +138,9 @@
val dialog = mBluetoothTileDialogDelegate.createDialog()
dialog.show()
- val recyclerView = dialog.requireViewById<RecyclerView>(R.id.device_list)
-
- assertThat(recyclerView).isNotNull()
- assertThat(recyclerView.visibility).isEqualTo(VISIBLE)
- assertThat(recyclerView.adapter).isNotNull()
- assertThat(recyclerView.layoutManager is LinearLayoutManager).isTrue()
+ verify(bluetoothDetailsContentManager).bind(any())
+ verify(bluetoothDetailsContentManager).start()
dialog.dismiss()
- }
-
- @Test
- fun testShowDialog_displayBluetoothDevice() {
- testScope.runTest {
- val dialog = mBluetoothTileDialogDelegate.createDialog()
- dialog.show()
- fakeSystemClock.setElapsedRealtime(Long.MAX_VALUE)
- mBluetoothTileDialogDelegate.onDeviceItemUpdated(
- dialog,
- listOf(deviceItem),
- showSeeAll = false,
- showPairNewDevice = false,
- )
-
- val recyclerView = dialog.requireViewById<RecyclerView>(R.id.device_list)
- val adapter = recyclerView?.adapter as BluetoothTileDialogDelegate.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)
- dialog.dismiss()
- }
- }
-
- @Test
- fun testDeviceItemViewHolder_cachedDeviceNotBusy() {
- testScope.runTest {
- deviceItem.isEnabled = true
-
- val view =
- LayoutInflater.from(mContext).inflate(R.layout.bluetooth_device_item, null, false)
- val viewHolder = mBluetoothTileDialogDelegate.Adapter().DeviceItemViewHolder(view)
- viewHolder.bind(deviceItem)
- val container = view.requireViewById<View>(R.id.bluetooth_device_row)
-
- assertThat(container).isNotNull()
- assertThat(container.isEnabled).isTrue()
- assertThat(container.hasOnClickListeners()).isTrue()
- val value by collectLastValue(mBluetoothTileDialogDelegate.deviceItemClick)
- runCurrent()
- container.performClick()
- runCurrent()
- assertThat(value).isNotNull()
- value?.let {
- assertThat(it.target).isEqualTo(DeviceItemClick.Target.ENTIRE_ROW)
- assertThat(it.clickedView).isEqualTo(container)
- assertThat(it.deviceItem).isEqualTo(deviceItem)
- }
- }
- }
-
- @Test
- fun testDeviceItemViewHolder_cachedDeviceBusy() {
- deviceItem.isEnabled = false
-
- val view =
- LayoutInflater.from(mContext).inflate(R.layout.bluetooth_device_item, null, false)
- val viewHolder =
- BluetoothTileDialogDelegate(
- uiProperties,
- CONTENT_HEIGHT,
- bluetoothTileDialogCallback,
- {},
- dispatcher,
- fakeSystemClock,
- uiEventLogger,
- logger,
- sysuiDialogFactory,
- kosmos.shadeDialogContextInteractor,
- )
- .Adapter()
- .DeviceItemViewHolder(view)
- viewHolder.bind(deviceItem)
- val container = view.requireViewById<View>(R.id.bluetooth_device_row)
-
- assertThat(container).isNotNull()
- assertThat(container.isEnabled).isFalse()
- assertThat(container.hasOnClickListeners()).isTrue()
- }
-
- @Test
- fun testDeviceItemViewHolder_clickActionIcon() {
- testScope.runTest {
- deviceItem.isEnabled = true
-
- val view =
- LayoutInflater.from(mContext).inflate(R.layout.bluetooth_device_item, null, false)
- val viewHolder = mBluetoothTileDialogDelegate.Adapter().DeviceItemViewHolder(view)
- viewHolder.bind(deviceItem)
- val actionIconView = view.requireViewById<View>(R.id.gear_icon)
-
- assertThat(actionIconView).isNotNull()
- assertThat(actionIconView.hasOnClickListeners()).isTrue()
- val value by collectLastValue(mBluetoothTileDialogDelegate.deviceItemClick)
- runCurrent()
- actionIconView.performClick()
- runCurrent()
- assertThat(value).isNotNull()
- value?.let {
- assertThat(it.target).isEqualTo(DeviceItemClick.Target.ACTION_ICON)
- assertThat(it.clickedView).isEqualTo(actionIconView)
- assertThat(it.deviceItem).isEqualTo(deviceItem)
- }
- }
- }
-
- @Test
- fun testOnDeviceUpdated_hideSeeAll_showPairNew() {
- testScope.runTest {
- val dialog = mBluetoothTileDialogDelegate.createDialog()
- dialog.show()
- fakeSystemClock.setElapsedRealtime(Long.MAX_VALUE)
- mBluetoothTileDialogDelegate.onDeviceItemUpdated(
- dialog,
- listOf(deviceItem),
- showSeeAll = false,
- showPairNewDevice = true,
- )
-
- val seeAllButton = dialog.requireViewById<View>(R.id.see_all_button)
- val pairNewButton = dialog.requireViewById<View>(R.id.pair_new_device_button)
- val recyclerView = dialog.requireViewById<RecyclerView>(R.id.device_list)
- val adapter = recyclerView?.adapter as BluetoothTileDialogDelegate.Adapter
- val scrollViewContent = dialog.requireViewById<View>(R.id.scroll_view)
-
- assertThat(seeAllButton).isNotNull()
- assertThat(seeAllButton.visibility).isEqualTo(GONE)
- assertThat(pairNewButton).isNotNull()
- assertThat(pairNewButton.visibility).isEqualTo(VISIBLE)
- assertThat(adapter.itemCount).isEqualTo(1)
- assertThat(scrollViewContent.layoutParams.height).isEqualTo(WRAP_CONTENT)
- dialog.dismiss()
- }
- }
-
- @Test
- fun testShowDialog_cachedHeightLargerThanMinHeight_displayFromCachedHeight() {
- testScope.runTest {
- val cachedHeight = Int.MAX_VALUE
- val dialog =
- BluetoothTileDialogDelegate(
- BluetoothTileDialogViewModel.UiProperties.build(ENABLED, ENABLED),
- cachedHeight,
- bluetoothTileDialogCallback,
- {},
- dispatcher,
- fakeSystemClock,
- uiEventLogger,
- logger,
- sysuiDialogFactory,
- kosmos.shadeDialogContextInteractor,
- )
- .createDialog()
- dialog.show()
- assertThat(dialog.requireViewById<View>(R.id.scroll_view).layoutParams.height)
- .isEqualTo(cachedHeight)
- dialog.dismiss()
- }
- }
-
- @Test
- fun testShowDialog_cachedHeightLessThanMinHeight_displayFromUiProperties() {
- testScope.runTest {
- val dialog =
- BluetoothTileDialogDelegate(
- BluetoothTileDialogViewModel.UiProperties.build(ENABLED, ENABLED),
- MATCH_PARENT,
- bluetoothTileDialogCallback,
- {},
- dispatcher,
- fakeSystemClock,
- uiEventLogger,
- logger,
- sysuiDialogFactory,
- kosmos.shadeDialogContextInteractor,
- )
- .createDialog()
- dialog.show()
- assertThat(dialog.requireViewById<View>(R.id.scroll_view).layoutParams.height)
- .isGreaterThan(MATCH_PARENT)
- dialog.dismiss()
- }
- }
-
- @Test
- fun testShowDialog_bluetoothEnabled_autoOnToggleGone() {
- testScope.runTest {
- val dialog =
- BluetoothTileDialogDelegate(
- BluetoothTileDialogViewModel.UiProperties.build(ENABLED, ENABLED),
- MATCH_PARENT,
- bluetoothTileDialogCallback,
- {},
- dispatcher,
- fakeSystemClock,
- uiEventLogger,
- logger,
- sysuiDialogFactory,
- kosmos.shadeDialogContextInteractor,
- )
- .createDialog()
- dialog.show()
- assertThat(
- dialog.requireViewById<View>(R.id.bluetooth_auto_on_toggle_layout).visibility
- )
- .isEqualTo(GONE)
- dialog.dismiss()
- }
- }
-
- @Test
- fun testOnAudioSharingButtonUpdated_visibleActive_activateButton() {
- testScope.runTest {
- val dialog = mBluetoothTileDialogDelegate.createDialog()
- dialog.show()
- fakeSystemClock.setElapsedRealtime(Long.MAX_VALUE)
- mBluetoothTileDialogDelegate.onAudioSharingButtonUpdated(
- dialog,
- visibility = VISIBLE,
- label = null,
- isActive = true,
- )
-
- val audioSharingButton = dialog.requireViewById<View>(R.id.audio_sharing_button)
-
- assertThat(audioSharingButton).isNotNull()
- assertThat(audioSharingButton.visibility).isEqualTo(VISIBLE)
- assertThat(audioSharingButton.isActivated).isTrue()
- dialog.dismiss()
- }
- }
-
- @Test
- fun testOnAudioSharingButtonUpdated_visibleNotActive_inactivateButton() {
- testScope.runTest {
- val dialog = mBluetoothTileDialogDelegate.createDialog()
- dialog.show()
- fakeSystemClock.setElapsedRealtime(Long.MAX_VALUE)
- mBluetoothTileDialogDelegate.onAudioSharingButtonUpdated(
- dialog,
- visibility = VISIBLE,
- label = null,
- isActive = false,
- )
-
- val audioSharingButton = dialog.requireViewById<View>(R.id.audio_sharing_button)
-
- assertThat(audioSharingButton).isNotNull()
- assertThat(audioSharingButton.visibility).isEqualTo(VISIBLE)
- assertThat(audioSharingButton.isActivated).isFalse()
- dialog.dismiss()
- }
- }
-
- @Test
- fun testOnAudioSharingButtonUpdated_gone_inactivateButton() {
- testScope.runTest {
- val dialog = mBluetoothTileDialogDelegate.createDialog()
- dialog.show()
- fakeSystemClock.setElapsedRealtime(Long.MAX_VALUE)
- mBluetoothTileDialogDelegate.onAudioSharingButtonUpdated(
- dialog,
- visibility = GONE,
- label = null,
- isActive = false,
- )
-
- val audioSharingButton = dialog.requireViewById<View>(R.id.audio_sharing_button)
-
- assertThat(audioSharingButton).isNotNull()
- assertThat(audioSharingButton.visibility).isEqualTo(GONE)
- assertThat(audioSharingButton.isActivated).isFalse()
- dialog.dismiss()
- }
+ verify(bluetoothDetailsContentManager).releaseView()
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModelTest.kt
index a56c2cb..fa34579 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModelTest.kt
@@ -78,8 +78,6 @@
private lateinit var bluetoothTileDialogViewModel: BluetoothTileDialogViewModel
- @Mock private lateinit var bluetoothStateInteractor: BluetoothStateInteractor
-
@Mock private lateinit var bluetoothDeviceMetadataInteractor: BluetoothDeviceMetadataInteractor
@Mock private lateinit var deviceItemInteractor: DeviceItemInteractor
@@ -108,9 +106,16 @@
@Mock private lateinit var bluetoothTileDialogDelegate: BluetoothTileDialogDelegate
+ @Mock
+ private lateinit var bluetoothDetailsContentManagerFactory:
+ BluetoothDetailsContentManager.Factory
+
+ @Mock private lateinit var bluetoothDetailsContentManager: BluetoothDetailsContentManager
+
@Mock private lateinit var sysuiDialog: SystemUIDialog
@Mock private lateinit var expandable: Expandable
@Mock private lateinit var controller: DialogTransitionAnimator.Controller
+ @Mock private lateinit var mockView: View
private val sharedPreferences = FakeSharedPreferences()
@@ -131,7 +136,7 @@
localBluetoothManager,
bluetoothTileDialogLogger,
testScope.backgroundScope,
- dispatcher
+ dispatcher,
),
// TODO(b/316822488): Create FakeBluetoothAutoOnInteractor.
BluetoothAutoOnInteractor(
@@ -139,7 +144,7 @@
localBluetoothManager,
bluetoothAdapter,
testScope.backgroundScope,
- dispatcher
+ dispatcher,
)
),
kosmos.audioSharingInteractor,
@@ -153,7 +158,8 @@
dispatcher,
dispatcher,
sharedPreferences,
- mBluetoothTileDialogDelegateDelegateFactory
+ mBluetoothTileDialogDelegateDelegateFactory,
+ bluetoothDetailsContentManagerFactory,
)
whenever(deviceItemInteractor.deviceItemUpdate).thenReturn(MutableSharedFlow())
whenever(deviceItemInteractor.deviceItemUpdateRequest)
@@ -163,20 +169,34 @@
whenever(mBluetoothTileDialogDelegateDelegateFactory.create(any(), anyInt(), any(), any()))
.thenReturn(bluetoothTileDialogDelegate)
whenever(bluetoothTileDialogDelegate.createDialog()).thenReturn(sysuiDialog)
+ whenever(bluetoothTileDialogDelegate.contentManager)
+ .thenReturn(bluetoothDetailsContentManager)
+ whenever(
+ bluetoothDetailsContentManagerFactory.create(
+ any(),
+ anyInt(),
+ any(),
+ anyBoolean(),
+ any(),
+ )
+ )
+ .thenReturn(bluetoothDetailsContentManager)
whenever(sysuiDialog.context).thenReturn(mContext)
- whenever(bluetoothTileDialogDelegate.bluetoothStateToggle)
+ whenever(bluetoothDetailsContentManager.bluetoothStateToggle)
.thenReturn(getMutableStateFlow(false))
- whenever(bluetoothTileDialogDelegate.deviceItemClick).thenReturn(MutableSharedFlow())
- whenever(bluetoothTileDialogDelegate.contentHeight).thenReturn(getMutableStateFlow(0))
- whenever(bluetoothTileDialogDelegate.bluetoothAutoOnToggle)
+ whenever(bluetoothDetailsContentManager.deviceItemClick)
+ .thenReturn(getMutableStateFlow(null))
+ whenever(bluetoothDetailsContentManager.contentHeight).thenReturn(getMutableStateFlow(0))
+ whenever(bluetoothDetailsContentManager.bluetoothAutoOnToggle)
.thenReturn(getMutableStateFlow(false))
whenever(expandable.dialogTransitionController(any())).thenReturn(controller)
+ whenever(mockView.context).thenReturn(mContext)
}
@Test
- fun testShowDialog_noAnimation() {
+ fun testShowDetailsContent_noAnimation() {
testScope.runTest {
- bluetoothTileDialogViewModel.showDialog(null)
+ bluetoothTileDialogViewModel.showDetailsContent(null, null)
runCurrent()
verify(mDialogTransitionAnimator, never()).show(any(), any(), any())
@@ -184,9 +204,9 @@
}
@Test
- fun testShowDialog_animated() {
+ fun testShowDetailsContent_animated() {
testScope.runTest {
- bluetoothTileDialogViewModel.showDialog(expandable)
+ bluetoothTileDialogViewModel.showDetailsContent(expandable, null)
runCurrent()
verify(mDialogTransitionAnimator).show(any(), any(), anyBoolean())
@@ -194,10 +214,21 @@
}
@Test
- fun testShowDialog_animated_callInBackgroundThread() {
+ fun testShowDetailsContent_animated_inDetailsView() {
+ testScope.runTest {
+ bluetoothTileDialogViewModel.showDetailsContent(expandable, mockView)
+ runCurrent()
+
+ verify(bluetoothDetailsContentManager).bind(mockView)
+ verify(bluetoothDetailsContentManager).start()
+ }
+ }
+
+ @Test
+ fun testShowDetailsContent_animated_callInBackgroundThread() {
testScope.runTest {
backgroundExecutor.execute {
- bluetoothTileDialogViewModel.showDialog(expandable)
+ bluetoothTileDialogViewModel.showDetailsContent(expandable, null)
runCurrent()
verify(mDialogTransitionAnimator).show(any(), any(), anyBoolean())
@@ -206,9 +237,22 @@
}
@Test
- fun testShowDialog_fetchDeviceItem() {
+ fun testShowDetailsContent_animated_callInBackgroundThread_inDetailsView() {
testScope.runTest {
- bluetoothTileDialogViewModel.showDialog(null)
+ backgroundExecutor.execute {
+ bluetoothTileDialogViewModel.showDetailsContent(expandable, mockView)
+ runCurrent()
+
+ verify(bluetoothDetailsContentManager).bind(mockView)
+ verify(bluetoothDetailsContentManager).start()
+ }
+ }
+ }
+
+ @Test
+ fun testShowDetailsContent_fetchDeviceItem() {
+ testScope.runTest {
+ bluetoothTileDialogViewModel.showDetailsContent(null, null)
runCurrent()
verify(deviceItemInteractor).deviceItemUpdate
@@ -219,7 +263,7 @@
fun testStartSettingsActivity_activityLaunched_dialogDismissed() {
testScope.runTest {
whenever(deviceItem.cachedBluetoothDevice).thenReturn(cachedBluetoothDevice)
- bluetoothTileDialogViewModel.showDialog(null)
+ bluetoothTileDialogViewModel.showDetailsContent(null, null)
runCurrent()
val clickedView = View(context)
@@ -236,7 +280,7 @@
val actual =
BluetoothTileDialogViewModel.UiProperties.build(
isBluetoothEnabled = true,
- isAutoOnToggleFeatureAvailable = true
+ isAutoOnToggleFeatureAvailable = true,
)
assertThat(actual.autoOnToggleVisibility).isEqualTo(GONE)
}
@@ -248,7 +292,7 @@
val actual =
BluetoothTileDialogViewModel.UiProperties.build(
isBluetoothEnabled = false,
- isAutoOnToggleFeatureAvailable = true
+ isAutoOnToggleFeatureAvailable = true,
)
assertThat(actual.autoOnToggleVisibility).isEqualTo(VISIBLE)
}
@@ -260,7 +304,7 @@
val actual =
BluetoothTileDialogViewModel.UiProperties.build(
isBluetoothEnabled = false,
- isAutoOnToggleFeatureAvailable = false
+ isAutoOnToggleFeatureAvailable = false,
)
assertThat(actual.autoOnToggleVisibility).isEqualTo(GONE)
}
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 330b887..1305b0c 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
@@ -238,7 +238,8 @@
tile.handleClick(null)
- verify(bluetoothTileDialogViewModel).showDialog(null)
+ verify(bluetoothTileDialogViewModel)
+ .showDetailsContent(/* expandable= */ null, /* view= */ null)
}
@Test