From 410afb390259dd89da1cbf60840810e7a2649258 Mon Sep 17 00:00:00 2001 From: My Name Date: Thu, 6 Feb 2025 15:54:28 -0800 Subject: Rename BluetoothTileDialogViewModel to BluetoothDetailsContentViewModel Test: BluetoothDetailsContentManagerTest, BluetoothTileDialogDelegateTest, BluetoothDetailsContentViewModelTest, BluetoothTileTest Flag: NONE rename file only Change-Id: Iba92aa095c9e957acec5d8a2ab9a1893cc5f49d3 --- packages/SystemUI/Android.bp | 4 +- .../bluetooth/qsdialog/BluetoothDetailsContent.kt | 2 +- .../qsdialog/BluetoothDetailsContentManager.kt | 6 +- .../qsdialog/BluetoothDetailsContentViewModel.kt | 426 ++++++++++++++++++++ .../qsdialog/BluetoothDetailsViewModel.kt | 2 +- .../qsdialog/BluetoothTileDialogDelegate.kt | 4 +- .../qsdialog/BluetoothTileDialogViewModel.kt | 429 --------------------- .../android/systemui/qs/tiles/BluetoothTile.java | 12 +- .../qsdialog/BluetoothDetailsContentManagerTest.kt | 8 +- .../BluetoothDetailsContentViewModelTest.kt | 330 ++++++++++++++++ .../qsdialog/BluetoothTileDialogDelegateTest.kt | 2 +- .../qsdialog/BluetoothTileDialogViewModelTest.kt | 330 ---------------- .../android/systemui/qs/tiles/BluetoothTileTest.kt | 12 +- 13 files changed, 782 insertions(+), 785 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentViewModel.kt delete mode 100644 packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModel.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentViewModelTest.kt delete mode 100644 packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModelTest.kt diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp index 744388f47d0e..63c2fb9ebbfe 100644 --- a/packages/SystemUI/Android.bp +++ b/packages/SystemUI/Android.bp @@ -313,11 +313,11 @@ filegroup { "tests/src/**/systemui/temporarydisplay/TemporaryViewDisplayControllerTest.kt", "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/BluetoothDetailsContentViewModelTest.kt", + "tests/src/**/systemui/bluetooth/qsdialog/BluetoothStateInteractorTest.kt", "tests/src/**/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegateTest.kt", "tests/src/**/systemui/bluetooth/qsdialog/BluetoothTileDialogRepositoryTest.kt", - "tests/src/**/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModelTest.kt", "tests/src/**/systemui/bluetooth/qsdialog/DeviceItemFactoryTest.kt", "tests/src/**/systemui/bluetooth/qsdialog/DeviceItemInteractorTest.kt", "tests/src/**/systemui/broadcast/UserBroadcastDispatcherTest.kt", diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContent.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContent.kt index 710fde5c2130..c6644562a9cb 100644 --- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContent.kt +++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContent.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.viewinterop.AndroidView import com.android.systemui.res.R @Composable -fun BluetoothDetailsContent(detailsContentViewModel: BluetoothTileDialogViewModel) { +fun BluetoothDetailsContent(detailsContentViewModel: BluetoothDetailsContentViewModel) { AndroidView( modifier = Modifier.fillMaxSize(), factory = { context -> diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt index d873f41309cc..eebcf0b0f0c1 100644 --- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt +++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManager.kt @@ -62,7 +62,7 @@ data class DeviceItemClick(val deviceItem: DeviceItem, val clickedView: View, va class BluetoothDetailsContentManager @AssistedInject constructor( - @Assisted private val initialUiProperties: BluetoothTileDialogViewModel.UiProperties, + @Assisted private val initialUiProperties: BluetoothDetailsContentViewModel.UiProperties, @Assisted private val cachedContentHeight: Int, @Assisted private val bluetoothTileDialogCallback: BluetoothTileDialogCallback, @Assisted private val isInDialog: Boolean, @@ -114,7 +114,7 @@ constructor( @AssistedFactory interface Factory { fun create( - initialUiProperties: BluetoothTileDialogViewModel.UiProperties, + initialUiProperties: BluetoothDetailsContentViewModel.UiProperties, cachedContentHeight: Int, dialogCallback: BluetoothTileDialogCallback, isInDialog: Boolean, @@ -226,7 +226,7 @@ constructor( internal fun onBluetoothStateUpdated( isEnabled: Boolean, - uiProperties: BluetoothTileDialogViewModel.UiProperties, + uiProperties: BluetoothDetailsContentViewModel.UiProperties, ) { bluetoothToggle.apply { isChecked = isEnabled diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentViewModel.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentViewModel.kt new file mode 100644 index 000000000000..ff2d9efa1b58 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentViewModel.kt @@ -0,0 +1,426 @@ +/* + * 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.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +import android.view.ViewGroup +import androidx.annotation.DimenRes +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import com.android.app.tracing.coroutines.launchTraced as launch +import com.android.internal.jank.InteractionJankMonitor +import com.android.internal.logging.UiEventLogger +import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast +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.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 +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.produce +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext + +/** + * ViewModel for Bluetooth Dialog or Bluetooth Details View after clicking on the Bluetooth QS tile. + */ +@SysUISingleton +class BluetoothDetailsContentViewModel +@Inject +constructor( + private val deviceItemInteractor: DeviceItemInteractor, + private val deviceItemActionInteractor: DeviceItemActionInteractor, + private val bluetoothStateInteractor: BluetoothStateInteractor, + private val bluetoothAutoOnInteractor: BluetoothAutoOnInteractor, + private val audioSharingInteractor: AudioSharingInteractor, + private val audioSharingButtonViewModelFactory: AudioSharingButtonViewModel.Factory, + private val bluetoothDeviceMetadataInteractor: BluetoothDeviceMetadataInteractor, + private val dialogTransitionAnimator: DialogTransitionAnimator, + private val activityStarter: ActivityStarter, + private val uiEventLogger: UiEventLogger, + private val logger: BluetoothTileDialogLogger, + @Application private val coroutineScope: CoroutineScope, + @Main private val mainDispatcher: CoroutineDispatcher, + @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 details content. + * + * @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 showDetailsContent(expandable: Expandable?, view: View?) { + cancelJob() + + job = + coroutineScope.launch(context = mainDispatcher) { + var updateDeviceItemJob: Job? + var updateDialogUiJob: Job? = null + 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() + // 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) + } + + // deviceItemUpdate is emitted when device item list is done fetching, update UI and + // stop the progress bar. + combine( + deviceItemInteractor.deviceItemUpdate, + deviceItemInteractor.showSeeAllUpdate, + ) { deviceItem, showSeeAll -> + updateDialogUiJob?.cancel() + updateDialogUiJob = launch { + contentManager.apply { + onDeviceItemUpdated( + deviceItem, + showSeeAll, + showPairNewDevice = + bluetoothStateInteractor.isBluetoothEnabled(), + ) + animateProgressBar(false) + } + } + } + .launchIn(this) + + // deviceItemUpdateRequest is emitted when a bluetooth callback is called, re-fetch + // the device item list and animate the progress bar. + merge( + deviceItemInteractor.deviceItemUpdateRequest, + bluetoothDeviceMetadataInteractor.metadataUpdate, + if ( + audioSharingInteractor.audioSharingAvailable() && + audioSharingInteractor.qsDialogImprovementAvailable() + ) { + audioSharingInteractor.audioSourceStateUpdate + } else { + emptyFlow() + }, + ) + .onEach { + contentManager.animateProgressBar(true) + updateDeviceItemJob?.cancel() + updateDeviceItemJob = launch { + deviceItemInteractor.updateDeviceItems( + context, + DeviceFetchTrigger.BLUETOOTH_CALLBACK_RECEIVED, + ) + } + } + .launchIn(this) + + if (audioSharingInteractor.audioSharingAvailable()) { + if (audioSharingInteractor.qsDialogImprovementAvailable()) { + launch { audioSharingInteractor.handleAudioSourceWhenReady() } + } + + audioSharingButtonViewModelFactory.create().run { + audioSharingButtonStateUpdate + .onEach { + when (it) { + is AudioSharingButtonState.Visible -> { + contentManager.onAudioSharingButtonUpdated( + VISIBLE, + context.getString(it.resId), + it.isActive, + ) + } + is AudioSharingButtonState.Gone -> { + contentManager.onAudioSharingButtonUpdated( + GONE, + label = null, + isActive = false, + ) + } + } + } + .launchIn(this@launch) + launch { activate() } + } + } + + // bluetoothStateUpdate is emitted when bluetooth on/off state is changed, re-fetch + // the device item list. + bluetoothStateInteractor.bluetoothStateUpdate + .onEach { + contentManager.onBluetoothStateUpdated( + it, + UiProperties.build(it, isAutoOnToggleFeatureAvailable()), + ) + updateDeviceItemJob?.cancel() + updateDeviceItemJob = launch { + deviceItemInteractor.updateDeviceItems( + context, + DeviceFetchTrigger.BLUETOOTH_STATE_CHANGE_RECEIVED, + ) + } + } + .launchIn(this) + + // bluetoothStateToggle is emitted when user toggles the bluetooth state switch, + // send the new value to the bluetoothStateInteractor and animate the progress bar. + contentManager.bluetoothStateToggle + .filterNotNull() + .onEach { + contentManager.animateProgressBar(true) + bluetoothStateInteractor.setBluetoothEnabled(it) + } + .launchIn(this) + + // deviceItemClick is emitted when user clicked on a device item. + contentManager.deviceItemClick + .filterNotNull() + .onEach { + when (it.target) { + DeviceItemClick.Target.ENTIRE_ROW -> { + deviceItemActionInteractor.onClick(it.deviceItem, dialog) + logger.logDeviceClick( + it.deviceItem.cachedBluetoothDevice.address, + it.deviceItem.type, + ) + } + + DeviceItemClick.Target.ACTION_ICON -> { + deviceItemActionInteractor.onActionIconClick(it.deviceItem) { intent + -> + startSettingsActivity(intent, it.clickedView) + } + } + } + } + .launchIn(this) + + // contentHeight is emitted when the dialog is dismissed. + contentManager.contentHeight + .filterNotNull() + .onEach { + withContext(backgroundDispatcher) { + sharedPreferences.edit().putInt(CONTENT_HEIGHT_PREF_KEY, it).apply() + } + } + .launchIn(this) + + if (isAutoOnToggleFeatureAvailable()) { + // bluetoothAutoOnUpdate is emitted when bluetooth auto on on/off state is + // changed. + bluetoothAutoOnInteractor.isEnabled + .onEach { + contentManager.onBluetoothAutoOnUpdated( + it, + if (it) R.string.turn_on_bluetooth_auto_info_enabled + else R.string.turn_on_bluetooth_auto_info_disabled, + ) + } + .launchIn(this) + + // bluetoothAutoOnToggle is emitted when user toggles the bluetooth auto on + // switch, send the new value to the bluetoothAutoOnInteractor. + contentManager.bluetoothAutoOnToggle + .filterNotNull() + .onEach { bluetoothAutoOnInteractor.setEnabled(it) } + .launchIn(this) + } + + produce { awaitClose { dialog?.cancel() } } + } + } + + private suspend fun createBluetoothTileDialog(): BluetoothTileDialogDelegate { + return bluetoothDialogDelegateFactory.create( + getUiProperties(), + getCachedContentHeight(), + this@BluetoothDetailsContentViewModel, + { cancelJob() }, + ) + } + + private suspend fun createContentManager(): BluetoothDetailsContentManager { + return bluetoothDetailsContentManagerFactory.create( + getUiProperties(), + getCachedContentHeight(), + this@BluetoothDetailsContentViewModel, + /* 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) + } + + override fun onPairNewDeviceClicked(view: View) { + uiEventLogger.log(BluetoothTileDialogUiEvent.PAIR_NEW_DEVICE_CLICKED) + startSettingsActivity(Intent(ACTION_PAIR_NEW_DEVICE), view) + } + + override fun onAudioSharingButtonClicked(view: View) { + uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_AUDIO_SHARING_BUTTON_CLICKED) + val intent = + Intent(ACTION_AUDIO_SHARING).apply { + putExtra( + EXTRA_SHOW_FRAGMENT_ARGUMENTS, + Bundle().apply { + putBoolean(LocalBluetoothLeBroadcast.EXTRA_START_LE_AUDIO_SHARING, true) + }, + ) + } + startSettingsActivity(intent, view) + } + + private fun cancelJob() { + job?.cancel() + job = null + } + + private fun startSettingsActivity(intent: Intent, view: View) { + if (job?.isActive == true) { + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + val controller = dialogTransitionAnimator.createActivityTransitionController(view) + // The controller will be null when the screen is locked and going to show the + // primary bouncer. In this case we dismiss the dialog manually. + if (controller == null) { + cancelJob() + } + activityStarter.postStartActivityDismissingKeyguard(intent, 0, controller) + } + } + + @VisibleForTesting + internal suspend fun isAutoOnToggleFeatureAvailable() = + bluetoothAutoOnInteractor.isAutoOnSupported() + + companion object { + private const val INTERACTION_JANK_TAG = "bluetooth_tile_dialog" + private const val CONTENT_HEIGHT_PREF_KEY = Prefs.Key.BLUETOOTH_TILE_DIALOG_CONTENT_HEIGHT + private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" + + private fun getSubtitleResId(isBluetoothEnabled: Boolean) = + if (isBluetoothEnabled) R.string.quick_settings_bluetooth_tile_subtitle + else R.string.bt_is_off + } + + data class UiProperties( + @StringRes val subTitleResId: Int, + val autoOnToggleVisibility: Int, + @DimenRes val scrollViewMinHeightResId: Int, + ) { + companion object { + internal fun build( + isBluetoothEnabled: Boolean, + isAutoOnToggleFeatureAvailable: Boolean, + ) = + UiProperties( + subTitleResId = getSubtitleResId(isBluetoothEnabled), + autoOnToggleVisibility = + if (isAutoOnToggleFeatureAvailable && !isBluetoothEnabled) VISIBLE + else GONE, + scrollViewMinHeightResId = + if (isAutoOnToggleFeatureAvailable) + R.dimen.bluetooth_dialog_scroll_view_min_height_with_auto_on + else R.dimen.bluetooth_dialog_scroll_view_min_height, + ) + } + } +} + +interface BluetoothTileDialogCallback { + fun onSeeAllClicked(view: View) + + fun onPairNewDeviceClicked(view: View) + + fun onAudioSharingButtonClicked(view: View) +} diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsViewModel.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsViewModel.kt index 44475318a61e..5863a9385234 100644 --- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsViewModel.kt @@ -20,7 +20,7 @@ import com.android.systemui.plugins.qs.TileDetailsViewModel class BluetoothDetailsViewModel( private val onSettingsClick: () -> Unit, - val detailsContentViewModel: BluetoothTileDialogViewModel, + val detailsContentViewModel: BluetoothDetailsContentViewModel, ) : TileDetailsViewModel() { override fun clickOnSettingsButton() { onSettingsClick() 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 01be820a2fde..c55f60587527 100644 --- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegate.kt +++ b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogDelegate.kt @@ -32,7 +32,7 @@ import dagger.assisted.AssistedInject class BluetoothTileDialogDelegate @AssistedInject constructor( - @Assisted private val initialUiProperties: BluetoothTileDialogViewModel.UiProperties, + @Assisted private val initialUiProperties: BluetoothDetailsContentViewModel.UiProperties, @Assisted private val cachedContentHeight: Int, @Assisted private val bluetoothTileDialogCallback: BluetoothTileDialogCallback, @Assisted private val dismissListener: Runnable, @@ -48,7 +48,7 @@ constructor( @AssistedFactory interface Factory { fun create( - initialUiProperties: BluetoothTileDialogViewModel.UiProperties, + initialUiProperties: BluetoothDetailsContentViewModel.UiProperties, cachedContentHeight: Int, dialogCallback: BluetoothTileDialogCallback, dimissListener: Runnable, diff --git a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModel.kt b/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModel.kt deleted file mode 100644 index 308c9d10db93..000000000000 --- a/packages/SystemUI/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModel.kt +++ /dev/null @@ -1,429 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.systemui.bluetooth.qsdialog - -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.os.Bundle -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup -import androidx.annotation.DimenRes -import androidx.annotation.StringRes -import androidx.annotation.VisibleForTesting -import com.android.app.tracing.coroutines.launchTraced as launch -import com.android.internal.jank.InteractionJankMonitor -import com.android.internal.logging.UiEventLogger -import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast -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.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 -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.produce -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.withContext - -/** - * 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 -class BluetoothTileDialogViewModel -@Inject -constructor( - private val deviceItemInteractor: DeviceItemInteractor, - private val deviceItemActionInteractor: DeviceItemActionInteractor, - private val bluetoothStateInteractor: BluetoothStateInteractor, - private val bluetoothAutoOnInteractor: BluetoothAutoOnInteractor, - private val audioSharingInteractor: AudioSharingInteractor, - private val audioSharingButtonViewModelFactory: AudioSharingButtonViewModel.Factory, - private val bluetoothDeviceMetadataInteractor: BluetoothDeviceMetadataInteractor, - private val dialogTransitionAnimator: DialogTransitionAnimator, - private val activityStarter: ActivityStarter, - private val uiEventLogger: UiEventLogger, - private val logger: BluetoothTileDialogLogger, - @Application private val coroutineScope: CoroutineScope, - @Main private val mainDispatcher: CoroutineDispatcher, - @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 details content. - * - * @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 showDetailsContent(expandable: Expandable?, view: View?) { - cancelJob() - - job = - coroutineScope.launch(context = mainDispatcher) { - var updateDeviceItemJob: Job? - var updateDialogUiJob: Job? = null - 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() - // 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) - } - - // deviceItemUpdate is emitted when device item list is done fetching, update UI and - // stop the progress bar. - combine( - deviceItemInteractor.deviceItemUpdate, - deviceItemInteractor.showSeeAllUpdate, - ) { deviceItem, showSeeAll -> - updateDialogUiJob?.cancel() - updateDialogUiJob = launch { - contentManager.apply { - onDeviceItemUpdated( - deviceItem, - showSeeAll, - showPairNewDevice = - bluetoothStateInteractor.isBluetoothEnabled(), - ) - animateProgressBar(false) - } - } - } - .launchIn(this) - - // deviceItemUpdateRequest is emitted when a bluetooth callback is called, re-fetch - // the device item list and animate the progress bar. - merge( - deviceItemInteractor.deviceItemUpdateRequest, - bluetoothDeviceMetadataInteractor.metadataUpdate, - if ( - audioSharingInteractor.audioSharingAvailable() && - audioSharingInteractor.qsDialogImprovementAvailable() - ) { - audioSharingInteractor.audioSourceStateUpdate - } else { - emptyFlow() - }, - ) - .onEach { - contentManager.animateProgressBar(true) - updateDeviceItemJob?.cancel() - updateDeviceItemJob = launch { - deviceItemInteractor.updateDeviceItems( - context, - DeviceFetchTrigger.BLUETOOTH_CALLBACK_RECEIVED, - ) - } - } - .launchIn(this) - - if (audioSharingInteractor.audioSharingAvailable()) { - if (audioSharingInteractor.qsDialogImprovementAvailable()) { - launch { audioSharingInteractor.handleAudioSourceWhenReady() } - } - - audioSharingButtonViewModelFactory.create().run { - audioSharingButtonStateUpdate - .onEach { - when (it) { - is AudioSharingButtonState.Visible -> { - contentManager.onAudioSharingButtonUpdated( - VISIBLE, - context.getString(it.resId), - it.isActive, - ) - } - is AudioSharingButtonState.Gone -> { - contentManager.onAudioSharingButtonUpdated( - GONE, - label = null, - isActive = false, - ) - } - } - } - .launchIn(this@launch) - launch { activate() } - } - } - - // bluetoothStateUpdate is emitted when bluetooth on/off state is changed, re-fetch - // the device item list. - bluetoothStateInteractor.bluetoothStateUpdate - .onEach { - contentManager.onBluetoothStateUpdated( - it, - UiProperties.build(it, isAutoOnToggleFeatureAvailable()), - ) - updateDeviceItemJob?.cancel() - updateDeviceItemJob = launch { - deviceItemInteractor.updateDeviceItems( - context, - DeviceFetchTrigger.BLUETOOTH_STATE_CHANGE_RECEIVED, - ) - } - } - .launchIn(this) - - // bluetoothStateToggle is emitted when user toggles the bluetooth state switch, - // send the new value to the bluetoothStateInteractor and animate the progress bar. - contentManager.bluetoothStateToggle - .filterNotNull() - .onEach { - contentManager.animateProgressBar(true) - bluetoothStateInteractor.setBluetoothEnabled(it) - } - .launchIn(this) - - // deviceItemClick is emitted when user clicked on a device item. - contentManager.deviceItemClick - .filterNotNull() - .onEach { - when (it.target) { - DeviceItemClick.Target.ENTIRE_ROW -> { - deviceItemActionInteractor.onClick(it.deviceItem, dialog) - logger.logDeviceClick( - it.deviceItem.cachedBluetoothDevice.address, - it.deviceItem.type, - ) - } - - DeviceItemClick.Target.ACTION_ICON -> { - deviceItemActionInteractor.onActionIconClick(it.deviceItem) { intent - -> - startSettingsActivity(intent, it.clickedView) - } - } - } - } - .launchIn(this) - - // contentHeight is emitted when the dialog is dismissed. - contentManager.contentHeight - .filterNotNull() - .onEach { - withContext(backgroundDispatcher) { - sharedPreferences.edit().putInt(CONTENT_HEIGHT_PREF_KEY, it).apply() - } - } - .launchIn(this) - - if (isAutoOnToggleFeatureAvailable()) { - // bluetoothAutoOnUpdate is emitted when bluetooth auto on on/off state is - // changed. - bluetoothAutoOnInteractor.isEnabled - .onEach { - contentManager.onBluetoothAutoOnUpdated( - it, - if (it) R.string.turn_on_bluetooth_auto_info_enabled - else R.string.turn_on_bluetooth_auto_info_disabled, - ) - } - .launchIn(this) - - // bluetoothAutoOnToggle is emitted when user toggles the bluetooth auto on - // switch, send the new value to the bluetoothAutoOnInteractor. - contentManager.bluetoothAutoOnToggle - .filterNotNull() - .onEach { bluetoothAutoOnInteractor.setEnabled(it) } - .launchIn(this) - } - - produce { awaitClose { dialog?.cancel() } } - } - } - - private suspend fun createBluetoothTileDialog(): BluetoothTileDialogDelegate { - return bluetoothDialogDelegateFactory.create( - 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) - } - - override fun onPairNewDeviceClicked(view: View) { - uiEventLogger.log(BluetoothTileDialogUiEvent.PAIR_NEW_DEVICE_CLICKED) - startSettingsActivity(Intent(ACTION_PAIR_NEW_DEVICE), view) - } - - override fun onAudioSharingButtonClicked(view: View) { - uiEventLogger.log(BluetoothTileDialogUiEvent.BLUETOOTH_AUDIO_SHARING_BUTTON_CLICKED) - val intent = - Intent(ACTION_AUDIO_SHARING).apply { - putExtra( - EXTRA_SHOW_FRAGMENT_ARGUMENTS, - Bundle().apply { - putBoolean(LocalBluetoothLeBroadcast.EXTRA_START_LE_AUDIO_SHARING, true) - }, - ) - } - startSettingsActivity(intent, view) - } - - private fun cancelJob() { - job?.cancel() - job = null - } - - private fun startSettingsActivity(intent: Intent, view: View) { - if (job?.isActive == true) { - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - val controller = dialogTransitionAnimator.createActivityTransitionController(view) - // The controller will be null when the screen is locked and going to show the - // primary bouncer. In this case we dismiss the dialog manually. - if (controller == null) { - cancelJob() - } - activityStarter.postStartActivityDismissingKeyguard(intent, 0, controller) - } - } - - @VisibleForTesting - internal suspend fun isAutoOnToggleFeatureAvailable() = - bluetoothAutoOnInteractor.isAutoOnSupported() - - companion object { - private const val INTERACTION_JANK_TAG = "bluetooth_tile_dialog" - private const val CONTENT_HEIGHT_PREF_KEY = Prefs.Key.BLUETOOTH_TILE_DIALOG_CONTENT_HEIGHT - private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" - - private fun getSubtitleResId(isBluetoothEnabled: Boolean) = - if (isBluetoothEnabled) R.string.quick_settings_bluetooth_tile_subtitle - else R.string.bt_is_off - } - - data class UiProperties( - @StringRes val subTitleResId: Int, - val autoOnToggleVisibility: Int, - @DimenRes val scrollViewMinHeightResId: Int, - ) { - companion object { - internal fun build( - isBluetoothEnabled: Boolean, - isAutoOnToggleFeatureAvailable: Boolean, - ) = - UiProperties( - subTitleResId = getSubtitleResId(isBluetoothEnabled), - autoOnToggleVisibility = - if (isAutoOnToggleFeatureAvailable && !isBluetoothEnabled) VISIBLE - else GONE, - scrollViewMinHeightResId = - if (isAutoOnToggleFeatureAvailable) - R.dimen.bluetooth_dialog_scroll_view_min_height_with_auto_on - else R.dimen.bluetooth_dialog_scroll_view_min_height, - ) - } - } -} - -interface BluetoothTileDialogCallback { - fun onSeeAllClicked(view: View) - - fun onPairNewDeviceClicked(view: View) - - fun onAudioSharingButtonClicked(view: View) -} 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 973265c6c9b1..fd5861fed20c 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/BluetoothTile.java @@ -43,8 +43,8 @@ import com.android.settingslib.bluetooth.BluetoothUtils; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.satellite.SatelliteDialogUtils; import com.android.systemui.animation.Expandable; +import com.android.systemui.bluetooth.qsdialog.BluetoothDetailsContentViewModel; import com.android.systemui.bluetooth.qsdialog.BluetoothDetailsViewModel; -import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogViewModel; import com.android.systemui.dagger.qualifiers.Background; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.flags.FeatureFlags; @@ -84,7 +84,7 @@ public class BluetoothTile extends QSTileImpl { private final Executor mExecutor; - private final BluetoothTileDialogViewModel mDialogViewModel; + private final BluetoothDetailsContentViewModel mDetailsContentViewModel; private final FeatureFlags mFeatureFlags; @Nullable @@ -104,7 +104,7 @@ public class BluetoothTile extends QSTileImpl { QSLogger qsLogger, BluetoothController bluetoothController, FeatureFlags featureFlags, - BluetoothTileDialogViewModel dialogViewModel + BluetoothDetailsContentViewModel detailsContentViewModel ) { super(host, uiEventLogger, backgroundLooper, mainHandler, falsingManager, metricsLogger, statusBarStateController, activityStarter, qsLogger); @@ -112,7 +112,7 @@ public class BluetoothTile extends QSTileImpl { mController.observe(getLifecycle(), mCallback); mExecutor = new HandlerExecutor(mainHandler); mFeatureFlags = featureFlags; - mDialogViewModel = dialogViewModel; + mDetailsContentViewModel = detailsContentViewModel; } @Override @@ -133,7 +133,7 @@ public class BluetoothTile extends QSTileImpl { callback.accept(new BluetoothDetailsViewModel(() -> { longClick(null); return null; - }, mDialogViewModel)) + }, mDetailsContentViewModel)) ); return true; } @@ -158,7 +158,7 @@ public class BluetoothTile extends QSTileImpl { private void handleClickEvent(@Nullable Expandable expandable) { if (mFeatureFlags.isEnabled(Flags.BLUETOOTH_QS_TILE_DIALOG)) { - mDialogViewModel.showDetailsContent(expandable, /* view= */ null); + mDetailsContentViewModel.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 index 6ed990d513cb..9c932695f295 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManagerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentManagerTest.kt @@ -87,7 +87,7 @@ class BluetoothDetailsContentManagerTest : SysuiTestCase() { private val fakeSystemClock = FakeSystemClock() private val uiProperties = - BluetoothTileDialogViewModel.UiProperties.build( + BluetoothDetailsContentViewModel.UiProperties.build( isBluetoothEnabled = ENABLED, isAutoOnToggleFeatureAvailable = ENABLED, ) @@ -314,7 +314,7 @@ class BluetoothDetailsContentManagerTest : SysuiTestCase() { val cachedHeight = Int.MAX_VALUE val contentManager = BluetoothDetailsContentManager( - BluetoothTileDialogViewModel.UiProperties.build(ENABLED, ENABLED), + BluetoothDetailsContentViewModel.UiProperties.build(ENABLED, ENABLED), cachedHeight, bluetoothTileDialogCallback, /* isInDialog= */ true, @@ -339,7 +339,7 @@ class BluetoothDetailsContentManagerTest : SysuiTestCase() { testScope.runTest { val contentManager = BluetoothDetailsContentManager( - BluetoothTileDialogViewModel.UiProperties.build(ENABLED, ENABLED), + BluetoothDetailsContentViewModel.UiProperties.build(ENABLED, ENABLED), MATCH_PARENT, bluetoothTileDialogCallback, /* isInDialog= */ true, @@ -364,7 +364,7 @@ class BluetoothDetailsContentManagerTest : SysuiTestCase() { testScope.runTest { val contentManager = BluetoothDetailsContentManager( - BluetoothTileDialogViewModel.UiProperties.build(ENABLED, ENABLED), + BluetoothDetailsContentViewModel.UiProperties.build(ENABLED, ENABLED), MATCH_PARENT, bluetoothTileDialogCallback, /* isInDialog= */ true, diff --git a/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentViewModelTest.kt new file mode 100644 index 000000000000..bfc5361b6129 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothDetailsContentViewModelTest.kt @@ -0,0 +1,330 @@ +/* + * 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.bluetooth.BluetoothAdapter +import android.platform.test.annotations.EnableFlags +import android.testing.TestableLooper +import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE +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.settingslib.bluetooth.LocalBluetoothManager +import com.android.settingslib.flags.Flags +import com.android.systemui.SysuiTestCase +import com.android.systemui.animation.DialogTransitionAnimator +import com.android.systemui.animation.Expandable +import com.android.systemui.kosmos.testDispatcher +import com.android.systemui.kosmos.testScope +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.statusbar.phone.SystemUIDialog +import com.android.systemui.testKosmos +import com.android.systemui.util.FakeSharedPreferences +import com.android.systemui.util.concurrency.FakeExecutor +import com.android.systemui.util.kotlin.getMutableStateFlow +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.nullable +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.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +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.anyInt +import org.mockito.Mock +import org.mockito.Mockito.anyBoolean +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule + +@SmallTest +@RunWith(AndroidJUnit4::class) +@TestableLooper.RunWithLooper(setAsMainLooper = true) +@EnableFlags(Flags.FLAG_BLUETOOTH_QS_TILE_DIALOG_AUTO_ON_TOGGLE) +class BluetoothDetailsContentViewModelTest : SysuiTestCase() { + + @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule() + private val kosmos = testKosmos() + private val fakeSystemClock = FakeSystemClock() + private val backgroundExecutor = FakeExecutor(fakeSystemClock) + + private lateinit var bluetoothDetailsContentViewModel: BluetoothDetailsContentViewModel + + @Mock private lateinit var bluetoothDeviceMetadataInteractor: BluetoothDeviceMetadataInteractor + + @Mock private lateinit var deviceItemInteractor: DeviceItemInteractor + + @Mock private lateinit var deviceItemActionInteractor: DeviceItemActionInteractor + + @Mock private lateinit var activityStarter: ActivityStarter + + @Mock private lateinit var mDialogTransitionAnimator: DialogTransitionAnimator + + @Mock private lateinit var cachedBluetoothDevice: CachedBluetoothDevice + + @Mock private lateinit var deviceItem: DeviceItem + + @Mock private lateinit var uiEventLogger: UiEventLogger + + @Mock private lateinit var bluetoothAdapter: BluetoothAdapter + + @Mock private lateinit var localBluetoothManager: LocalBluetoothManager + + @Mock private lateinit var bluetoothTileDialogLogger: BluetoothTileDialogLogger + + @Mock + private lateinit var mBluetoothTileDialogDelegateDelegateFactory: + BluetoothTileDialogDelegate.Factory + + @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() + + private lateinit var dispatcher: CoroutineDispatcher + private lateinit var testScope: TestScope + + @Before + fun setUp() { + dispatcher = kosmos.testDispatcher + testScope = kosmos.testScope + // TODO(b/364515243): use real object instead of mock + whenever(kosmos.deviceItemInteractor.deviceItemUpdate).thenReturn(MutableSharedFlow()) + bluetoothDetailsContentViewModel = + BluetoothDetailsContentViewModel( + deviceItemInteractor, + deviceItemActionInteractor, + BluetoothStateInteractor( + localBluetoothManager, + bluetoothTileDialogLogger, + testScope.backgroundScope, + dispatcher, + ), + // TODO(b/316822488): Create FakeBluetoothAutoOnInteractor. + BluetoothAutoOnInteractor( + BluetoothAutoOnRepository( + localBluetoothManager, + bluetoothAdapter, + testScope.backgroundScope, + dispatcher, + ) + ), + kosmos.audioSharingInteractor, + kosmos.audioSharingButtonViewModelFactory, + bluetoothDeviceMetadataInteractor, + mDialogTransitionAnimator, + activityStarter, + uiEventLogger, + bluetoothTileDialogLogger, + testScope.backgroundScope, + dispatcher, + dispatcher, + sharedPreferences, + mBluetoothTileDialogDelegateDelegateFactory, + bluetoothDetailsContentManagerFactory, + ) + whenever(deviceItemInteractor.deviceItemUpdate).thenReturn(MutableSharedFlow()) + whenever(deviceItemInteractor.deviceItemUpdateRequest) + .thenReturn(MutableStateFlow(Unit).asStateFlow()) + whenever(deviceItemInteractor.showSeeAllUpdate).thenReturn(getMutableStateFlow(false)) + whenever(bluetoothDeviceMetadataInteractor.metadataUpdate).thenReturn(MutableSharedFlow()) + 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(bluetoothDetailsContentManager.bluetoothStateToggle) + .thenReturn(getMutableStateFlow(false)) + 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 testShowDetailsContent_noAnimation() { + testScope.runTest { + bluetoothDetailsContentViewModel.showDetailsContent(null, null) + runCurrent() + + verify(mDialogTransitionAnimator, never()).show(any(), any(), any()) + } + } + + @Test + fun testShowDetailsContent_animated() { + testScope.runTest { + bluetoothDetailsContentViewModel.showDetailsContent(expandable, null) + runCurrent() + + verify(mDialogTransitionAnimator).show(any(), any(), anyBoolean()) + } + } + + @Test + fun testShowDetailsContent_animated_inDetailsView() { + testScope.runTest { + bluetoothDetailsContentViewModel.showDetailsContent(expandable, mockView) + runCurrent() + + verify(bluetoothDetailsContentManager).bind(mockView) + verify(bluetoothDetailsContentManager).start() + } + } + + @Test + fun testShowDetailsContent_animated_callInBackgroundThread() { + testScope.runTest { + backgroundExecutor.execute { + bluetoothDetailsContentViewModel.showDetailsContent(expandable, null) + runCurrent() + + verify(mDialogTransitionAnimator).show(any(), any(), anyBoolean()) + } + } + } + + @Test + fun testShowDetailsContent_animated_callInBackgroundThread_inDetailsView() { + testScope.runTest { + backgroundExecutor.execute { + bluetoothDetailsContentViewModel.showDetailsContent(expandable, mockView) + runCurrent() + + verify(bluetoothDetailsContentManager).bind(mockView) + verify(bluetoothDetailsContentManager).start() + } + } + } + + @Test + fun testShowDetailsContent_fetchDeviceItem() { + testScope.runTest { + bluetoothDetailsContentViewModel.showDetailsContent(null, null) + runCurrent() + + verify(deviceItemInteractor).deviceItemUpdate + } + } + + @Test + fun testStartSettingsActivity_activityLaunched_dialogDismissed() { + testScope.runTest { + whenever(deviceItem.cachedBluetoothDevice).thenReturn(cachedBluetoothDevice) + bluetoothDetailsContentViewModel.showDetailsContent(null, null) + runCurrent() + + val clickedView = View(context) + bluetoothDetailsContentViewModel.onPairNewDeviceClicked(clickedView) + + verify(uiEventLogger).log(BluetoothTileDialogUiEvent.PAIR_NEW_DEVICE_CLICKED) + verify(activityStarter).postStartActivityDismissingKeyguard(any(), anyInt(), nullable()) + } + } + + @Test + fun testBuildUiProperties_bluetoothOn_shouldHideAutoOn() { + testScope.runTest { + val actual = + BluetoothDetailsContentViewModel.UiProperties.build( + isBluetoothEnabled = true, + isAutoOnToggleFeatureAvailable = true, + ) + assertThat(actual.autoOnToggleVisibility).isEqualTo(GONE) + } + } + + @Test + fun testBuildUiProperties_bluetoothOff_shouldShowAutoOn() { + testScope.runTest { + val actual = + BluetoothDetailsContentViewModel.UiProperties.build( + isBluetoothEnabled = false, + isAutoOnToggleFeatureAvailable = true, + ) + assertThat(actual.autoOnToggleVisibility).isEqualTo(VISIBLE) + } + } + + @Test + fun testBuildUiProperties_bluetoothOff_autoOnFeatureUnavailable_shouldHideAutoOn() { + testScope.runTest { + val actual = + BluetoothDetailsContentViewModel.UiProperties.build( + isBluetoothEnabled = false, + isAutoOnToggleFeatureAvailable = false, + ) + assertThat(actual.autoOnToggleVisibility).isEqualTo(GONE) + } + } + + @Test + fun testIsAutoOnToggleFeatureAvailable_returnTrue() { + testScope.runTest { + whenever(bluetoothAdapter.isAutoOnSupported).thenReturn(true) + + val actual = bluetoothDetailsContentViewModel.isAutoOnToggleFeatureAvailable() + assertThat(actual).isTrue() + } + } + + @Test + fun testIsAutoOnToggleFeatureAvailable_returnFalse() { + testScope.runTest { + whenever(bluetoothAdapter.isAutoOnSupported).thenReturn(false) + + val actual = bluetoothDetailsContentViewModel.isAutoOnToggleFeatureAvailable() + assertThat(actual).isFalse() + } + } +} 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 2788f1d95382..7f75b8f03533 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 @@ -80,7 +80,7 @@ class BluetoothTileDialogDelegateTest : SysuiTestCase() { @Mock private lateinit var dialogTransitionAnimator: DialogTransitionAnimator private val uiProperties = - BluetoothTileDialogViewModel.UiProperties.build( + BluetoothDetailsContentViewModel.UiProperties.build( isBluetoothEnabled = ENABLED, isAutoOnToggleFeatureAvailable = ENABLED, ) 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 deleted file mode 100644 index 47a834be9b9c..000000000000 --- a/packages/SystemUI/tests/src/com/android/systemui/bluetooth/qsdialog/BluetoothTileDialogViewModelTest.kt +++ /dev/null @@ -1,330 +0,0 @@ -/* - * Copyright (C) 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.systemui.bluetooth.qsdialog - -import android.bluetooth.BluetoothAdapter -import android.platform.test.annotations.EnableFlags -import android.testing.TestableLooper -import android.view.View -import android.view.View.GONE -import android.view.View.VISIBLE -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.settingslib.bluetooth.LocalBluetoothManager -import com.android.settingslib.flags.Flags -import com.android.systemui.SysuiTestCase -import com.android.systemui.animation.DialogTransitionAnimator -import com.android.systemui.animation.Expandable -import com.android.systemui.kosmos.testDispatcher -import com.android.systemui.kosmos.testScope -import com.android.systemui.plugins.ActivityStarter -import com.android.systemui.statusbar.phone.SystemUIDialog -import com.android.systemui.testKosmos -import com.android.systemui.util.FakeSharedPreferences -import com.android.systemui.util.concurrency.FakeExecutor -import com.android.systemui.util.kotlin.getMutableStateFlow -import com.android.systemui.util.mockito.any -import com.android.systemui.util.mockito.nullable -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.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -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.anyInt -import org.mockito.Mock -import org.mockito.Mockito.anyBoolean -import org.mockito.Mockito.never -import org.mockito.Mockito.verify -import org.mockito.junit.MockitoJUnit -import org.mockito.junit.MockitoRule - -@SmallTest -@RunWith(AndroidJUnit4::class) -@TestableLooper.RunWithLooper(setAsMainLooper = true) -@EnableFlags(Flags.FLAG_BLUETOOTH_QS_TILE_DIALOG_AUTO_ON_TOGGLE) -class BluetoothTileDialogViewModelTest : SysuiTestCase() { - - @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule() - private val kosmos = testKosmos() - private val fakeSystemClock = FakeSystemClock() - private val backgroundExecutor = FakeExecutor(fakeSystemClock) - - private lateinit var bluetoothTileDialogViewModel: BluetoothTileDialogViewModel - - @Mock private lateinit var bluetoothDeviceMetadataInteractor: BluetoothDeviceMetadataInteractor - - @Mock private lateinit var deviceItemInteractor: DeviceItemInteractor - - @Mock private lateinit var deviceItemActionInteractor: DeviceItemActionInteractor - - @Mock private lateinit var activityStarter: ActivityStarter - - @Mock private lateinit var mDialogTransitionAnimator: DialogTransitionAnimator - - @Mock private lateinit var cachedBluetoothDevice: CachedBluetoothDevice - - @Mock private lateinit var deviceItem: DeviceItem - - @Mock private lateinit var uiEventLogger: UiEventLogger - - @Mock private lateinit var bluetoothAdapter: BluetoothAdapter - - @Mock private lateinit var localBluetoothManager: LocalBluetoothManager - - @Mock private lateinit var bluetoothTileDialogLogger: BluetoothTileDialogLogger - - @Mock - private lateinit var mBluetoothTileDialogDelegateDelegateFactory: - BluetoothTileDialogDelegate.Factory - - @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() - - private lateinit var dispatcher: CoroutineDispatcher - private lateinit var testScope: TestScope - - @Before - fun setUp() { - dispatcher = kosmos.testDispatcher - testScope = kosmos.testScope - // TODO(b/364515243): use real object instead of mock - whenever(kosmos.deviceItemInteractor.deviceItemUpdate).thenReturn(MutableSharedFlow()) - bluetoothTileDialogViewModel = - BluetoothTileDialogViewModel( - deviceItemInteractor, - deviceItemActionInteractor, - BluetoothStateInteractor( - localBluetoothManager, - bluetoothTileDialogLogger, - testScope.backgroundScope, - dispatcher, - ), - // TODO(b/316822488): Create FakeBluetoothAutoOnInteractor. - BluetoothAutoOnInteractor( - BluetoothAutoOnRepository( - localBluetoothManager, - bluetoothAdapter, - testScope.backgroundScope, - dispatcher, - ) - ), - kosmos.audioSharingInteractor, - kosmos.audioSharingButtonViewModelFactory, - bluetoothDeviceMetadataInteractor, - mDialogTransitionAnimator, - activityStarter, - uiEventLogger, - bluetoothTileDialogLogger, - testScope.backgroundScope, - dispatcher, - dispatcher, - sharedPreferences, - mBluetoothTileDialogDelegateDelegateFactory, - bluetoothDetailsContentManagerFactory, - ) - whenever(deviceItemInteractor.deviceItemUpdate).thenReturn(MutableSharedFlow()) - whenever(deviceItemInteractor.deviceItemUpdateRequest) - .thenReturn(MutableStateFlow(Unit).asStateFlow()) - whenever(deviceItemInteractor.showSeeAllUpdate).thenReturn(getMutableStateFlow(false)) - whenever(bluetoothDeviceMetadataInteractor.metadataUpdate).thenReturn(MutableSharedFlow()) - 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(bluetoothDetailsContentManager.bluetoothStateToggle) - .thenReturn(getMutableStateFlow(false)) - 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 testShowDetailsContent_noAnimation() { - testScope.runTest { - bluetoothTileDialogViewModel.showDetailsContent(null, null) - runCurrent() - - verify(mDialogTransitionAnimator, never()).show(any(), any(), any()) - } - } - - @Test - fun testShowDetailsContent_animated() { - testScope.runTest { - bluetoothTileDialogViewModel.showDetailsContent(expandable, null) - runCurrent() - - verify(mDialogTransitionAnimator).show(any(), any(), anyBoolean()) - } - } - - @Test - 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.showDetailsContent(expandable, null) - runCurrent() - - verify(mDialogTransitionAnimator).show(any(), any(), anyBoolean()) - } - } - } - - @Test - 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.showDetailsContent(null, null) - runCurrent() - - verify(deviceItemInteractor).deviceItemUpdate - } - } - - @Test - fun testStartSettingsActivity_activityLaunched_dialogDismissed() { - testScope.runTest { - whenever(deviceItem.cachedBluetoothDevice).thenReturn(cachedBluetoothDevice) - bluetoothTileDialogViewModel.showDetailsContent(null, null) - runCurrent() - - val clickedView = View(context) - bluetoothTileDialogViewModel.onPairNewDeviceClicked(clickedView) - - verify(uiEventLogger).log(BluetoothTileDialogUiEvent.PAIR_NEW_DEVICE_CLICKED) - verify(activityStarter).postStartActivityDismissingKeyguard(any(), anyInt(), nullable()) - } - } - - @Test - fun testBuildUiProperties_bluetoothOn_shouldHideAutoOn() { - testScope.runTest { - val actual = - BluetoothTileDialogViewModel.UiProperties.build( - isBluetoothEnabled = true, - isAutoOnToggleFeatureAvailable = true, - ) - assertThat(actual.autoOnToggleVisibility).isEqualTo(GONE) - } - } - - @Test - fun testBuildUiProperties_bluetoothOff_shouldShowAutoOn() { - testScope.runTest { - val actual = - BluetoothTileDialogViewModel.UiProperties.build( - isBluetoothEnabled = false, - isAutoOnToggleFeatureAvailable = true, - ) - assertThat(actual.autoOnToggleVisibility).isEqualTo(VISIBLE) - } - } - - @Test - fun testBuildUiProperties_bluetoothOff_autoOnFeatureUnavailable_shouldHideAutoOn() { - testScope.runTest { - val actual = - BluetoothTileDialogViewModel.UiProperties.build( - isBluetoothEnabled = false, - isAutoOnToggleFeatureAvailable = false, - ) - assertThat(actual.autoOnToggleVisibility).isEqualTo(GONE) - } - } - - @Test - fun testIsAutoOnToggleFeatureAvailable_returnTrue() { - testScope.runTest { - whenever(bluetoothAdapter.isAutoOnSupported).thenReturn(true) - - val actual = bluetoothTileDialogViewModel.isAutoOnToggleFeatureAvailable() - assertThat(actual).isTrue() - } - } - - @Test - fun testIsAutoOnToggleFeatureAvailable_returnFalse() { - testScope.runTest { - whenever(bluetoothAdapter.isAutoOnSupported).thenReturn(false) - - val actual = bluetoothTileDialogViewModel.isAutoOnToggleFeatureAvailable() - assertThat(actual).isFalse() - } - } -} 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 1305b0c4c499..cfe34f446f36 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 @@ -16,7 +16,7 @@ import com.android.internal.telephony.flags.Flags import com.android.settingslib.Utils import com.android.settingslib.bluetooth.CachedBluetoothDevice import com.android.systemui.SysuiTestCase -import com.android.systemui.bluetooth.qsdialog.BluetoothTileDialogViewModel +import com.android.systemui.bluetooth.qsdialog.BluetoothDetailsContentViewModel import com.android.systemui.classifier.FalsingManagerFake import com.android.systemui.flags.FeatureFlagsClassic import com.android.systemui.plugins.ActivityStarter @@ -71,7 +71,7 @@ class BluetoothTileTest(flags: FlagsParameterization) : SysuiTestCase() { @Mock private lateinit var bluetoothController: BluetoothController @Mock private lateinit var uiEventLogger: QsEventLogger @Mock private lateinit var featureFlags: FeatureFlagsClassic - @Mock private lateinit var bluetoothTileDialogViewModel: BluetoothTileDialogViewModel + @Mock private lateinit var bluetoothDetailsContentViewModel: BluetoothDetailsContentViewModel @Mock private lateinit var clickJob: Job private lateinit var testableLooper: TestableLooper private lateinit var tile: FakeBluetoothTile @@ -96,7 +96,7 @@ class BluetoothTileTest(flags: FlagsParameterization) : SysuiTestCase() { qsLogger, bluetoothController, featureFlags, - bluetoothTileDialogViewModel, + bluetoothDetailsContentViewModel, ) tile.initialize() @@ -238,7 +238,7 @@ class BluetoothTileTest(flags: FlagsParameterization) : SysuiTestCase() { tile.handleClick(null) - verify(bluetoothTileDialogViewModel) + verify(bluetoothDetailsContentViewModel) .showDetailsContent(/* expandable= */ null, /* view= */ null) } @@ -308,7 +308,7 @@ class BluetoothTileTest(flags: FlagsParameterization) : SysuiTestCase() { qsLogger: QSLogger, bluetoothController: BluetoothController, featureFlags: FeatureFlagsClassic, - bluetoothTileDialogViewModel: BluetoothTileDialogViewModel, + bluetoothDetailsContentViewModel: BluetoothDetailsContentViewModel, ) : BluetoothTile( qsHost, @@ -322,7 +322,7 @@ class BluetoothTileTest(flags: FlagsParameterization) : SysuiTestCase() { qsLogger, bluetoothController, featureFlags, - bluetoothTileDialogViewModel, + bluetoothDetailsContentViewModel, ) { var restrictionChecked: String? = null -- cgit v1.2.3-59-g8ed1b