diff options
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 242d31a26b13..47a834be9b9c 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 @@ -76,8 +76,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 @@ -106,9 +104,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() @@ -129,7 +134,7 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() {                      localBluetoothManager,                      bluetoothTileDialogLogger,                      testScope.backgroundScope, -                    dispatcher +                    dispatcher,                  ),                  // TODO(b/316822488): Create FakeBluetoothAutoOnInteractor.                  BluetoothAutoOnInteractor( @@ -137,7 +142,7 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() {                          localBluetoothManager,                          bluetoothAdapter,                          testScope.backgroundScope, -                        dispatcher +                        dispatcher,                      )                  ),                  kosmos.audioSharingInteractor, @@ -151,7 +156,8 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() {                  dispatcher,                  dispatcher,                  sharedPreferences, -                mBluetoothTileDialogDelegateDelegateFactory +                mBluetoothTileDialogDelegateDelegateFactory, +                bluetoothDetailsContentManagerFactory,              )          whenever(deviceItemInteractor.deviceItemUpdate).thenReturn(MutableSharedFlow())          whenever(deviceItemInteractor.deviceItemUpdateRequest) @@ -161,20 +167,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()) @@ -182,9 +202,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()) @@ -192,10 +212,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()) @@ -204,9 +235,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 @@ -217,7 +261,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) @@ -234,7 +278,7 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() {              val actual =                  BluetoothTileDialogViewModel.UiProperties.build(                      isBluetoothEnabled = true, -                    isAutoOnToggleFeatureAvailable = true +                    isAutoOnToggleFeatureAvailable = true,                  )              assertThat(actual.autoOnToggleVisibility).isEqualTo(GONE)          } @@ -246,7 +290,7 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() {              val actual =                  BluetoothTileDialogViewModel.UiProperties.build(                      isBluetoothEnabled = false, -                    isAutoOnToggleFeatureAvailable = true +                    isAutoOnToggleFeatureAvailable = true,                  )              assertThat(actual.autoOnToggleVisibility).isEqualTo(VISIBLE)          } @@ -258,7 +302,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  |