diff options
| author | 2024-12-18 15:19:00 -0800 | |
|---|---|---|
| committer | 2025-01-30 14:22:56 -0800 | |
| commit | 89b270835b3563f2790fab1b57e610ec02c6acbe (patch) | |
| tree | 408738daf8a147d36e1b641b3d7fe1c8a5fedf8a | |
| parent | 6c118a5bdf32c987a2264f0952a74536ff422d6e (diff) | |
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
11 files changed, 1127 insertions, 819 deletions
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp index 0a7d880677d8..1f2890c2052e 100644 --- a/packages/SystemUI/Android.bp +++ b/packages/SystemUI/Android.bp @@ -314,6 +314,7 @@ filegroup { "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 0303048436c9..94fca218c74f 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 @@ constructor( 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 @@ constructor( 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 @@ constructor( ) } - 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 @@ constructor( 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 000000000000..0be28f3c5a97 --- /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 56caddfbd637..3e61c45c7f25 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 @@ package com.android.systemui.bluetooth.qsdialog 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 @@ internal constructor( @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 @@ internal constructor( } 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 @@ internal constructor( 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 bf04897f6d10..9492abbeb087 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.Prefs 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.merge 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 @@ constructor( @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 controller = - expandable?.dialogTransitionController( - DialogCuj( - InteractionJankMonitor.CUJ_SHADE_DIALOG_OPEN, - INTERACTION_JANK_TAG, + val dialog: SystemUIDialog? + val context: Context + + 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 @@ constructor( ) { 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 @@ constructor( }, ) .onEach { - dialogDelegate.animateProgressBar(dialog, true) + contentManager.animateProgressBar(true) updateDeviceItemJob?.cancel() updateDeviceItemJob = launch { deviceItemInteractor.updateDeviceItems( @@ -171,16 +202,14 @@ constructor( .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 @@ constructor( // the device item list. bluetoothStateInteractor.bluetoothStateUpdate .onEach { - dialogDelegate.onBluetoothStateUpdated( - dialog, + contentManager.onBluetoothStateUpdated( it, UiProperties.build(it, isAutoOnToggleFeatureAvailable()), ) @@ -214,16 +242,17 @@ constructor( // 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 @@ constructor( .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 @@ constructor( // 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 @@ constructor( // 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 cb4ec37a1a66..26996ac1db39 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.CoroutineDispatcher 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 @@ constructor( 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 0109e70a467e..1cfa6632a8b0 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 @@ public class BluetoothTile extends QSTileImpl<BooleanState> { 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 000000000000..6ed990d513cb --- /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 4396b0a42ae6..ffc75188ffa1 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 @@ class BluetoothTileDialogDelegateTest : SysuiTestCase() { @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule() - @Mock private lateinit var cachedBluetoothDevice: CachedBluetoothDevice + @Mock + private lateinit var bluetoothDetailsContentManagerFactory: + BluetoothDetailsContentManager.Factory - @Mock private lateinit var bluetoothTileDialogCallback: BluetoothTileDialogCallback + @Mock private lateinit var bluetoothDetailsContentManager: BluetoothDetailsContentManager - @Mock private lateinit var drawable: Drawable + @Mock private lateinit var bluetoothTileDialogCallback: BluetoothTileDialogCallback @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 @@ class BluetoothTileDialogDelegateTest : SysuiTestCase() { CONTENT_HEIGHT, bluetoothTileDialogCallback, {}, - dispatcher, - fakeSystemClock, uiEventLogger, - logger, sysuiDialogFactory, kosmos.shadeDialogContextInteractor, + bluetoothDetailsContentManagerFactory, ) whenever(sysuiDialogFactory.create(any(SystemUIDialog.Delegate::class.java), any())) @@ -138,17 +121,16 @@ class BluetoothTileDialogDelegateTest : SysuiTestCase() { ) } - 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 @@ class BluetoothTileDialogDelegateTest : SysuiTestCase() { 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 a56c2cb25542..fa3457997b96 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 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { 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 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { @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 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { localBluetoothManager, bluetoothTileDialogLogger, testScope.backgroundScope, - dispatcher + dispatcher, ), // TODO(b/316822488): Create FakeBluetoothAutoOnInteractor. BluetoothAutoOnInteractor( @@ -139,7 +144,7 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { localBluetoothManager, bluetoothAdapter, testScope.backgroundScope, - dispatcher + dispatcher, ) ), kosmos.audioSharingInteractor, @@ -153,7 +158,8 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { dispatcher, dispatcher, sharedPreferences, - mBluetoothTileDialogDelegateDelegateFactory + mBluetoothTileDialogDelegateDelegateFactory, + bluetoothDetailsContentManagerFactory, ) whenever(deviceItemInteractor.deviceItemUpdate).thenReturn(MutableSharedFlow()) whenever(deviceItemInteractor.deviceItemUpdateRequest) @@ -163,20 +169,34 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { 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 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { } @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 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { } @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 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { } @Test - fun testShowDialog_fetchDeviceItem() { + fun testShowDetailsContent_animated_callInBackgroundThread_inDetailsView() { + testScope.runTest { + backgroundExecutor.execute { + bluetoothTileDialogViewModel.showDetailsContent(expandable, mockView) + runCurrent() + + verify(bluetoothDetailsContentManager).bind(mockView) + verify(bluetoothDetailsContentManager).start() + } + } + } + + @Test + fun testShowDetailsContent_fetchDeviceItem() { testScope.runTest { - bluetoothTileDialogViewModel.showDialog(null) + bluetoothTileDialogViewModel.showDetailsContent(null, null) runCurrent() verify(deviceItemInteractor).deviceItemUpdate @@ -219,7 +263,7 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { 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 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { val actual = BluetoothTileDialogViewModel.UiProperties.build( isBluetoothEnabled = true, - isAutoOnToggleFeatureAvailable = true + isAutoOnToggleFeatureAvailable = true, ) assertThat(actual.autoOnToggleVisibility).isEqualTo(GONE) } @@ -248,7 +292,7 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { val actual = BluetoothTileDialogViewModel.UiProperties.build( isBluetoothEnabled = false, - isAutoOnToggleFeatureAvailable = true + isAutoOnToggleFeatureAvailable = true, ) assertThat(actual.autoOnToggleVisibility).isEqualTo(VISIBLE) } @@ -260,7 +304,7 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { 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 330b887b70a3..1305b0c4c499 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 @@ class BluetoothTileTest(flags: FlagsParameterization) : SysuiTestCase() { tile.handleClick(null) - verify(bluetoothTileDialogViewModel).showDialog(null) + verify(bluetoothTileDialogViewModel) + .showDetailsContent(/* expandable= */ null, /* view= */ null) } @Test |