diff options
| author | 2023-09-12 15:33:02 +0800 | |
|---|---|---|
| committer | 2023-09-25 02:42:57 +0000 | |
| commit | 23affd99cfe81a5ad1b41a9631725c33c9cb8740 (patch) | |
| tree | ca18e40ea5ac81b0ba9adf8450ebf58c16fee994 | |
| parent | f8a6914471652c2b300b3caff8b7a9ca73efef2a (diff) | |
Handle bluetooth callback and toggle switch, also moved `getDeviceItems` to background thread.
The dialog opens first with empty device list, then the list updates after `getDeviceItems` finishes.
Flag: BLUETOOTH_QS_TILE_DIALOG
Test: atest -c BluetoothTileDialogTest BluetoothTileDialogViewModelTest DeviceItemFactoryTest DeviceItemInteractorTest BluetoothTileDialogRepositoryTest BluetoothStateInteractorTest
Bug: b/298124674 b/299400510
Change-Id: Ib7d4ab9773b7741caf474a273c45d9568eb52566
9 files changed, 597 insertions, 138 deletions
diff --git a/packages/SystemUI/res/layout/bluetooth_tile_dialog.xml b/packages/SystemUI/res/layout/bluetooth_tile_dialog.xml index 95ad1e33bd48..9d14d0f36048 100644 --- a/packages/SystemUI/res/layout/bluetooth_tile_dialog.xml +++ b/packages/SystemUI/res/layout/bluetooth_tile_dialog.xml @@ -114,7 +114,8 @@ android:id="@+id/see_all_layout" style="@style/BluetoothTileDialog.Device" android:layout_height="64dp" - android:paddingStart="20dp"> + android:paddingStart="20dp" + android:visibility="gone"> <FrameLayout android:layout_width="24dp" diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractor.kt new file mode 100644 index 000000000000..efad9ec548f9 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractor.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.tiles.dialog.bluetooth + +import android.bluetooth.BluetoothAdapter.STATE_OFF +import android.bluetooth.BluetoothAdapter.STATE_ON +import com.android.settingslib.bluetooth.BluetoothCallback +import com.android.settingslib.bluetooth.LocalBluetoothManager +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +/** Holds business logic for the Bluetooth Dialog's bluetooth and device connection state */ +@SysUISingleton +internal class BluetoothStateInteractor +@Inject +constructor( + private val localBluetoothManager: LocalBluetoothManager?, + @Application private val coroutineScope: CoroutineScope, +) { + + internal val updateBluetoothStateFlow: StateFlow<Boolean?> = + conflatedCallbackFlow { + val listener = + object : BluetoothCallback { + override fun onBluetoothStateChanged(bluetoothState: Int) { + if (bluetoothState == STATE_ON || bluetoothState == STATE_OFF) { + super.onBluetoothStateChanged(bluetoothState) + trySendWithFailureLogging( + bluetoothState == STATE_ON, + TAG, + "onBluetoothStateChanged" + ) + } + } + } + localBluetoothManager?.eventManager?.registerCallback(listener) + awaitClose { localBluetoothManager?.eventManager?.unregisterCallback(listener) } + } + .stateIn( + coroutineScope, + SharingStarted.WhileSubscribed(replayExpirationMillis = 0), + initialValue = null + ) + + internal var isBluetoothEnabled: Boolean + get() = localBluetoothManager?.bluetoothAdapter?.isEnabled == true + set(value) { + if (isBluetoothEnabled != value) { + localBluetoothManager?.bluetoothAdapter?.apply { + if (value) enable() else disable() + } + } + } + + companion object { + private const val TAG = "BtStateInteractor" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialog.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialog.kt index feeebe7d8ec9..7a436a761594 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialog.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialog.kt @@ -20,55 +20,98 @@ import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View +import android.view.View.GONE +import android.view.View.VISIBLE import android.view.ViewGroup import android.widget.ImageView +import android.widget.Switch import android.widget.TextView import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.android.systemui.dagger.SysUISingleton import com.android.systemui.res.R import com.android.systemui.statusbar.phone.SystemUIDialog +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow /** Dialog for showing active, connected and saved bluetooth devices. */ @SysUISingleton internal class BluetoothTileDialog constructor( - deviceItem: List<DeviceItem>, - deviceItemOnClickCallback: DeviceItemOnClickCallback, + private val bluetoothToggleInitialValue: Boolean, + private val bluetoothTileDialogCallback: BluetoothTileDialogCallback, context: Context, ) : SystemUIDialog(context, DEFAULT_THEME, DEFAULT_DISMISS_ON_DEVICE_LOCK) { - private val deviceItemAdapter: Adapter = - Adapter(deviceItem.toMutableList(), deviceItemOnClickCallback) + private val mutableBluetoothStateSwitchedFlow: MutableStateFlow<Boolean?> = + MutableStateFlow(null) + internal val bluetoothStateSwitchedFlow + get() = mutableBluetoothStateSwitchedFlow.asStateFlow() + + private val mutableClickedFlow: MutableSharedFlow<Pair<DeviceItem, Int>> = + MutableSharedFlow(extraBufferCapacity = 1) + internal val deviceItemClickedFlow + get() = mutableClickedFlow.asSharedFlow() + + private val deviceItemAdapter: Adapter = Adapter() + + private lateinit var toggleView: Switch + private lateinit var doneButton: View + private lateinit var seeAllView: View + private lateinit var deviceListView: RecyclerView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(LayoutInflater.from(context).inflate(R.layout.bluetooth_tile_dialog, null)) - setupDoneButton() + toggleView = requireViewById(R.id.bluetooth_toggle) + doneButton = requireViewById(R.id.done_button) + seeAllView = requireViewById(R.id.see_all_layout) + deviceListView = requireViewById<RecyclerView>(R.id.device_list) + + setupToggle() setupRecyclerView() + + doneButton.setOnClickListener { dismiss() } } - internal fun onDeviceItemUpdated(deviceItem: DeviceItem, position: Int) { + internal fun onDeviceItemUpdated(deviceItem: List<DeviceItem>, showSeeAll: Boolean) { + seeAllView.visibility = if (showSeeAll) VISIBLE else GONE + deviceItemAdapter.refreshDeviceItemList(deviceItem) + } + + internal fun onDeviceItemUpdatedAtPosition(deviceItem: DeviceItem, position: Int) { deviceItemAdapter.refreshDeviceItem(deviceItem, position) } - private fun setupDoneButton() { - requireViewById<View>(R.id.done_button).setOnClickListener { dismiss() } + internal fun onBluetoothStateUpdated(isEnabled: Boolean) { + toggleView.isChecked = isEnabled + } + + private fun setupToggle() { + toggleView.isChecked = bluetoothToggleInitialValue + toggleView.setOnCheckedChangeListener { _, isChecked -> + mutableBluetoothStateSwitchedFlow.value = isChecked + } } private fun setupRecyclerView() { - requireViewById<RecyclerView>(R.id.device_list).apply { + deviceListView.apply { layoutManager = LinearLayoutManager(context) adapter = deviceItemAdapter } } - internal class Adapter( - private var deviceItem: MutableList<DeviceItem>, - private val onClickCallback: DeviceItemOnClickCallback - ) : RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() { + internal inner class Adapter : RecyclerView.Adapter<Adapter.DeviceItemViewHolder>() { + + init { + setHasStableIds(true) + } + + private val deviceItem: MutableList<DeviceItem> = mutableListOf() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeviceItemViewHolder { val view = @@ -79,36 +122,38 @@ constructor( override fun getItemCount() = deviceItem.size + override fun getItemId(position: Int) = position.toLong() + override fun onBindViewHolder(holder: DeviceItemViewHolder, position: Int) { val item = getItem(position) - holder.bind(item, position, onClickCallback) + holder.bind(item, position) } internal fun getItem(position: Int) = deviceItem[position] + internal fun refreshDeviceItemList(updated: List<DeviceItem>) { + deviceItem.clear() + deviceItem.addAll(updated) + notifyDataSetChanged() + } + internal fun refreshDeviceItem(updated: DeviceItem, position: Int) { deviceItem[position] = updated notifyItemChanged(position) } - internal class DeviceItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { + internal inner class DeviceItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { private val container = view.requireViewById<View>(R.id.bluetooth_device) private val nameView = view.requireViewById<TextView>(R.id.bluetooth_device_name) private val summaryView = view.requireViewById<TextView>(R.id.bluetooth_device_summary) private val iconView = view.requireViewById<ImageView>(R.id.bluetooth_device_icon) - internal fun bind( - item: DeviceItem, - position: Int, - deviceItemOnClickCallback: DeviceItemOnClickCallback - ) { + internal fun bind(item: DeviceItem, position: Int) { container.apply { isEnabled = item.isEnabled alpha = item.alpha background = item.background - setOnClickListener { - deviceItemOnClickCallback.onDeviceItemClicked(item, position) - } + setOnClickListener { mutableClickedFlow.tryEmit(Pair(item, position)) } } nameView.text = item.deviceName summaryView.text = item.connectionSummary @@ -125,5 +170,6 @@ constructor( internal companion object { const val ENABLED_ALPHA = 1.0f const val DISABLED_ALPHA = 0.3f + const val MAX_DEVICE_ITEM_ENTRY = 3 } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModel.kt index f1196a6ed17a..63f05318facc 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModel.kt @@ -17,13 +17,22 @@ package com.android.systemui.qs.tiles.dialog.bluetooth import android.content.Context -import android.os.Handler import android.view.View import androidx.annotation.VisibleForTesting import com.android.systemui.animation.DialogLaunchAnimator import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.qs.tiles.dialog.bluetooth.BluetoothTileDialog.Companion.MAX_DEVICE_ITEM_ENTRY +import com.android.systemui.statusbar.phone.SystemUIDialog import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch /** ViewModel for Bluetooth Dialog after clicking on the Bluetooth QS tile. */ @SysUISingleton @@ -31,13 +40,15 @@ internal class BluetoothTileDialogViewModel @Inject constructor( private val deviceItemInteractor: DeviceItemInteractor, + private val bluetoothStateInteractor: BluetoothStateInteractor, private val dialogLaunchAnimator: DialogLaunchAnimator, - @Main private val uiHandler: Handler -) : DeviceItemOnClickCallback { - private var deviceItems: List<DeviceItem> = emptyList() + @Application private val coroutineScope: CoroutineScope, + @Main private val mainDispatcher: CoroutineDispatcher, +) : BluetoothTileDialogCallback { - @VisibleForTesting - var dialog: BluetoothTileDialog? = null + private var job: Job? = null + + @VisibleForTesting internal var dialog: BluetoothTileDialog? = null /** * Shows the dialog. @@ -48,27 +59,79 @@ constructor( fun showDialog(context: Context, view: View?) { dismissDialog() - deviceItems = deviceItemInteractor.getDeviceItems(context) + var updateDeviceItemJob: Job? = null + + job = + coroutineScope.launch(mainDispatcher) { + dialog = createBluetoothTileDialog(context) + view?.let { dialogLaunchAnimator.showFromView(dialog!!, it) } ?: dialog!!.show() + updateDeviceItemJob?.cancel() + updateDeviceItemJob = launch { deviceItemInteractor.updateDeviceItems(context) } + + bluetoothStateInteractor.updateBluetoothStateFlow + .filterNotNull() + .onEach { + dialog!!.onBluetoothStateUpdated(it) + updateDeviceItemJob?.cancel() + updateDeviceItemJob = launch { + deviceItemInteractor.updateDeviceItems(context) + } + } + .launchIn(this) + + deviceItemInteractor.updateDeviceItemsFlow + .onEach { + updateDeviceItemJob?.cancel() + updateDeviceItemJob = launch { + deviceItemInteractor.updateDeviceItems(context) + } + } + .launchIn(this) + + deviceItemInteractor.deviceItemFlow + .filterNotNull() + .onEach { + dialog!!.onDeviceItemUpdated( + it.take(MAX_DEVICE_ITEM_ENTRY), + showSeeAll = it.size > MAX_DEVICE_ITEM_ENTRY + ) + } + .launchIn(this) - uiHandler.post { - dialog = BluetoothTileDialog(deviceItems, this, context) + dialog!! + .bluetoothStateSwitchedFlow + .filterNotNull() + .onEach { bluetoothStateInteractor.isBluetoothEnabled = it } + .launchIn(this) - view?.let { dialogLaunchAnimator.showFromView(dialog!!, it) } ?: dialog!!.show() - } + dialog!! + .deviceItemClickedFlow + .onEach { + if (deviceItemInteractor.updateDeviceItemOnClick(it.first)) { + dialog!!.onDeviceItemUpdatedAtPosition(it.first, it.second) + } + } + .launchIn(this) + } } - override fun onDeviceItemClicked(deviceItem: DeviceItem, position: Int) { - if (deviceItemInteractor.updateDeviceItemOnClick(deviceItem)) { - dialog?.onDeviceItemUpdated(deviceItem, position) - } + private fun createBluetoothTileDialog(context: Context): BluetoothTileDialog { + return BluetoothTileDialog( + bluetoothStateInteractor.isBluetoothEnabled, + this@BluetoothTileDialogViewModel, + context + ) + .apply { SystemUIDialog.registerDismissListener(this) { dismissDialog() } } } private fun dismissDialog() { + job?.cancel() + job = null dialog?.dismiss() dialog = null } } -internal interface DeviceItemOnClickCallback { - fun onDeviceItemClicked(deviceItem: DeviceItem, position: Int) +internal interface BluetoothTileDialogCallback { + // TODO(b/298124674): Add click events for gear, see all and pair new device. } diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractor.kt index bbe512798e37..6ffb61439c0c 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractor.kt @@ -20,9 +20,25 @@ import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.content.Context import android.media.AudioManager +import com.android.settingslib.bluetooth.BluetoothCallback import com.android.settingslib.bluetooth.BluetoothUtils +import com.android.settingslib.bluetooth.CachedBluetoothDevice +import com.android.settingslib.bluetooth.LocalBluetoothManager +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.withContext /** Holds business logic for the Bluetooth Dialog after clicking on the Bluetooth QS tile. */ @SysUISingleton @@ -31,8 +47,59 @@ internal class DeviceItemInteractor constructor( private val bluetoothTileDialogRepository: BluetoothTileDialogRepository, private val audioManager: AudioManager, - private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() + private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter(), + private val localBluetoothManager: LocalBluetoothManager?, + @Application private val coroutineScope: CoroutineScope, + @Background private val backgroundDispatcher: CoroutineDispatcher, ) { + + private val mutableDeviceItemFlow: MutableStateFlow<List<DeviceItem>?> = MutableStateFlow(null) + internal val deviceItemFlow + get() = mutableDeviceItemFlow.asStateFlow() + + internal val updateDeviceItemsFlow: SharedFlow<Unit> = + conflatedCallbackFlow { + val listener = + object : BluetoothCallback { + override fun onActiveDeviceChanged( + activeDevice: CachedBluetoothDevice?, + bluetoothProfile: Int + ) { + super.onActiveDeviceChanged(activeDevice, bluetoothProfile) + trySendWithFailureLogging(Unit, TAG, "onActiveDeviceChanged") + } + + override fun onConnectionStateChanged( + cachedDevice: CachedBluetoothDevice?, + state: Int + ) { + super.onConnectionStateChanged(cachedDevice, state) + trySendWithFailureLogging(Unit, TAG, "onConnectionStateChanged") + } + + override fun onDeviceAdded(cachedDevice: CachedBluetoothDevice) { + super.onDeviceAdded(cachedDevice) + trySendWithFailureLogging(Unit, TAG, "onDeviceAdded") + } + + override fun onProfileConnectionStateChanged( + cachedDevice: CachedBluetoothDevice, + state: Int, + bluetoothProfile: Int + ) { + super.onProfileConnectionStateChanged( + cachedDevice, + state, + bluetoothProfile + ) + trySendWithFailureLogging(Unit, TAG, "onProfileConnectionStateChanged") + } + } + localBluetoothManager?.eventManager?.registerCallback(listener) + awaitClose { localBluetoothManager?.eventManager?.unregisterCallback(listener) } + } + .shareIn(coroutineScope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0)) + private var deviceItemFactoryList: List<DeviceItemFactory> = listOf( AvailableMediaDeviceItemFactory(), @@ -47,16 +114,19 @@ constructor( DeviceItemType.SAVED_BLUETOOTH_DEVICE, ) - internal fun getDeviceItems(context: Context): List<DeviceItem> { - val mostRecentlyConnectedDevices = bluetoothAdapter?.mostRecentlyConnectedDevices + internal suspend fun updateDeviceItems(context: Context) { + withContext(backgroundDispatcher) { + val mostRecentlyConnectedDevices = bluetoothAdapter?.mostRecentlyConnectedDevices - return bluetoothTileDialogRepository.cachedDevices - .mapNotNull { cachedDevice -> - deviceItemFactoryList - .firstOrNull { it.isFilterMatched(cachedDevice, audioManager) } - ?.create(context, cachedDevice) - } - .sort(displayPriority, mostRecentlyConnectedDevices) + mutableDeviceItemFlow.value = + bluetoothTileDialogRepository.cachedDevices + .mapNotNull { cachedDevice -> + deviceItemFactoryList + .firstOrNull { it.isFilterMatched(cachedDevice, audioManager) } + ?.create(context, cachedDevice) + } + .sort(displayPriority, mostRecentlyConnectedDevices) + } } private fun List<DeviceItem>.sort( @@ -100,4 +170,8 @@ constructor( internal fun setDisplayPriorityForTesting(list: List<DeviceItemType>) { displayPriority = list } + + companion object { + private const val TAG = "DeviceItemInteractor" + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractorTest.kt new file mode 100644 index 000000000000..fc2b7a64957d --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothStateInteractorTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.tiles.dialog.bluetooth + +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import androidx.test.filters.SmallTest +import com.android.settingslib.bluetooth.LocalBluetoothAdapter +import com.android.settingslib.bluetooth.LocalBluetoothManager +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper(setAsMainLooper = true) +class BluetoothStateInteractorTest : SysuiTestCase() { + @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule() + private val testScope = TestScope() + + private lateinit var bluetoothStateInteractor: BluetoothStateInteractor + + @Mock private lateinit var bluetoothAdapter: LocalBluetoothAdapter + @Mock private lateinit var localBluetoothManager: LocalBluetoothManager + + @Before + fun setUp() { + bluetoothStateInteractor = + BluetoothStateInteractor(localBluetoothManager, testScope.backgroundScope) + `when`(localBluetoothManager.bluetoothAdapter).thenReturn(bluetoothAdapter) + } + + @Test + fun testGet_isBluetoothEnabled() { + testScope.runTest { + `when`(bluetoothAdapter.isEnabled).thenReturn(true) + + assertThat(bluetoothStateInteractor.isBluetoothEnabled).isTrue() + } + } + + @Test + fun testGet_isBluetoothDisabled() { + testScope.runTest { + `when`(bluetoothAdapter.isEnabled).thenReturn(false) + + assertThat(bluetoothStateInteractor.isBluetoothEnabled).isFalse() + } + } + + @Test + fun testSet_bluetoothEnabled() { + testScope.runTest { + `when`(bluetoothAdapter.isEnabled).thenReturn(false) + + bluetoothStateInteractor.isBluetoothEnabled = true + verify(bluetoothAdapter).enable() + } + } + + @Test + fun testSet_bluetoothNoChange() { + testScope.runTest { + `when`(bluetoothAdapter.isEnabled).thenReturn(false) + + bluetoothStateInteractor.isBluetoothEnabled = false + verify(bluetoothAdapter, never()).enable() + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogTest.kt index c00f6d885547..e1d177db2322 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogTest.kt @@ -21,6 +21,7 @@ import android.testing.AndroidTestingRunner import android.testing.TestableLooper import android.view.LayoutInflater import android.view.View +import android.view.View.GONE import android.view.View.VISIBLE import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -47,13 +48,14 @@ class BluetoothTileDialogTest : SysuiTestCase() { companion object { const val DEVICE_NAME = "device" const val DEVICE_CONNECTION_SUMMARY = "active" + const val ENABLED = true } @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule() @Mock private lateinit var cachedBluetoothDevice: CachedBluetoothDevice - @Mock private lateinit var deviceItemOnClickCallback: DeviceItemOnClickCallback + @Mock private lateinit var bluetoothTileDialogCallback: BluetoothTileDialogCallback @Mock private lateinit var drawable: Drawable @@ -63,7 +65,7 @@ class BluetoothTileDialogTest : SysuiTestCase() { @Before fun setUp() { - bluetoothTileDialog = BluetoothTileDialog(emptyList(), deviceItemOnClickCallback, mContext) + bluetoothTileDialog = BluetoothTileDialog(ENABLED, bluetoothTileDialogCallback, mContext) icon = Pair(drawable, DEVICE_NAME) deviceItem = DeviceItem( @@ -92,10 +94,9 @@ class BluetoothTileDialogTest : SysuiTestCase() { @Test fun testShowDialog_displayBluetoothDevice() { - bluetoothTileDialog = - BluetoothTileDialog(listOf(deviceItem), deviceItemOnClickCallback, mContext) - + bluetoothTileDialog = BluetoothTileDialog(ENABLED, bluetoothTileDialogCallback, mContext) bluetoothTileDialog.show() + bluetoothTileDialog.onDeviceItemUpdated(listOf(deviceItem), false) val recyclerView = bluetoothTileDialog.findViewById<RecyclerView>(R.id.device_list) val adapter = recyclerView?.adapter as BluetoothTileDialog.Adapter @@ -112,8 +113,11 @@ class BluetoothTileDialogTest : SysuiTestCase() { val view = LayoutInflater.from(mContext).inflate(R.layout.bluetooth_device_item, null, false) - val viewHolder = BluetoothTileDialog.Adapter.DeviceItemViewHolder(view) - viewHolder.bind(deviceItem, 0, deviceItemOnClickCallback) + val viewHolder = + BluetoothTileDialog(ENABLED, bluetoothTileDialogCallback, mContext) + .Adapter() + .DeviceItemViewHolder(view) + viewHolder.bind(deviceItem, 0) val container = view.findViewById<View>(R.id.bluetooth_device) assertThat(container).isNotNull() @@ -129,8 +133,11 @@ class BluetoothTileDialogTest : SysuiTestCase() { val view = LayoutInflater.from(mContext).inflate(R.layout.bluetooth_device_item, null, false) - val viewHolder = BluetoothTileDialog.Adapter.DeviceItemViewHolder(view) - viewHolder.bind(deviceItem, 0, deviceItemOnClickCallback) + val viewHolder = + BluetoothTileDialog(ENABLED, bluetoothTileDialogCallback, mContext) + .Adapter() + .DeviceItemViewHolder(view) + viewHolder.bind(deviceItem, 0) val container = view.findViewById<View>(R.id.bluetooth_device) assertThat(container).isNotNull() @@ -138,4 +145,19 @@ class BluetoothTileDialogTest : SysuiTestCase() { assertThat(container.alpha).isEqualTo(DISABLED_ALPHA) assertThat(container.hasOnClickListeners()).isTrue() } + + @Test + fun testOnDeviceUpdated_hideSeeAll() { + bluetoothTileDialog = BluetoothTileDialog(ENABLED, bluetoothTileDialogCallback, mContext) + bluetoothTileDialog.show() + bluetoothTileDialog.onDeviceItemUpdated(listOf(deviceItem), false) + + val seeAllLayout = bluetoothTileDialog.findViewById<View>(R.id.see_all_layout) + val recyclerView = bluetoothTileDialog.findViewById<RecyclerView>(R.id.device_list) + val adapter = recyclerView?.adapter as BluetoothTileDialog.Adapter + + assertThat(seeAllLayout).isNotNull() + assertThat(seeAllLayout!!.visibility).isEqualTo(GONE) + assertThat(adapter.itemCount).isEqualTo(1) + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModelTest.kt index dd5aacff9c42..975f1e25ba9d 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/BluetoothTileDialogViewModelTest.kt @@ -16,7 +16,6 @@ package com.android.systemui.qs.tiles.dialog.bluetooth -import android.os.Handler import android.testing.AndroidTestingRunner import android.testing.TestableLooper import android.widget.LinearLayout @@ -28,7 +27,13 @@ import com.android.systemui.util.mockito.any import com.android.systemui.util.mockito.nullable import com.android.systemui.util.time.FakeSystemClock import com.google.common.truth.Truth.assertThat -import org.junit.After +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test @@ -52,56 +57,87 @@ class BluetoothTileDialogViewModelTest : SysuiTestCase() { private lateinit var bluetoothTileDialogViewModel: BluetoothTileDialogViewModel + @Mock private lateinit var bluetoothStateInteractor: BluetoothStateInteractor + @Mock private lateinit var deviceItemInteractor: DeviceItemInteractor @Mock private lateinit var dialogLaunchAnimator: DialogLaunchAnimator - private lateinit var testableLooper: TestableLooper + private lateinit var scheduler: TestCoroutineScheduler + private lateinit var dispatcher: CoroutineDispatcher + private lateinit var testScope: TestScope @Before fun setUp() { - testableLooper = TestableLooper.get(this) - `when`(deviceItemInteractor.getDeviceItems(any())).thenReturn(emptyList()) + scheduler = TestCoroutineScheduler() + dispatcher = UnconfinedTestDispatcher(scheduler) + testScope = TestScope(dispatcher) bluetoothTileDialogViewModel = BluetoothTileDialogViewModel( deviceItemInteractor, + bluetoothStateInteractor, dialogLaunchAnimator, - Handler(testableLooper.looper) + testScope.backgroundScope, + dispatcher, ) - } - - @After - fun tearDown() { - testableLooper.processAllMessages() + `when`(deviceItemInteractor.deviceItemFlow).thenReturn(MutableStateFlow(null).asStateFlow()) + `when`(bluetoothStateInteractor.updateBluetoothStateFlow) + .thenReturn(MutableStateFlow(null).asStateFlow()) + `when`(deviceItemInteractor.updateDeviceItemsFlow) + .thenReturn(MutableStateFlow(Unit).asStateFlow()) + `when`(bluetoothStateInteractor.isBluetoothEnabled).thenReturn(true) } @Test fun testShowDialog_noAnimation() { - bluetoothTileDialogViewModel.showDialog(context, null) - testableLooper.processAllMessages() + testScope.runTest { + bluetoothTileDialogViewModel.showDialog(context, null) - assertThat(bluetoothTileDialogViewModel.dialog).isNotNull() - verify(dialogLaunchAnimator, never()).showFromView(any(), any(), any(), any()) - assertThat(bluetoothTileDialogViewModel.dialog?.isShowing).isTrue() + assertThat(bluetoothTileDialogViewModel.dialog).isNotNull() + verify(dialogLaunchAnimator, never()).showFromView(any(), any(), any(), any()) + assertThat(bluetoothTileDialogViewModel.dialog?.isShowing).isTrue() + } } @Test fun testShowDialog_animated() { - bluetoothTileDialogViewModel.showDialog(mContext, LinearLayout(mContext)) - testableLooper.processAllMessages() + testScope.runTest { + bluetoothTileDialogViewModel.showDialog(mContext, LinearLayout(mContext)) - assertThat(bluetoothTileDialogViewModel.dialog).isNotNull() - verify(dialogLaunchAnimator).showFromView(any(), any(), nullable(), anyBoolean()) + assertThat(bluetoothTileDialogViewModel.dialog).isNotNull() + verify(dialogLaunchAnimator).showFromView(any(), any(), nullable(), anyBoolean()) + } } @Test fun testShowDialog_animated_callInBackgroundThread() { - backgroundExecutor.execute { - bluetoothTileDialogViewModel.showDialog(mContext, LinearLayout(mContext)) - testableLooper.processAllMessages() + testScope.runTest { + backgroundExecutor.execute { + bluetoothTileDialogViewModel.showDialog(mContext, LinearLayout(mContext)) + + assertThat(bluetoothTileDialogViewModel.dialog).isNotNull() + verify(dialogLaunchAnimator).showFromView(any(), any(), nullable(), anyBoolean()) + } + } + } + + @Test + fun testShowDialog_fetchDeviceItem() { + testScope.runTest { + bluetoothTileDialogViewModel.showDialog(context, null) assertThat(bluetoothTileDialogViewModel.dialog).isNotNull() - verify(dialogLaunchAnimator).showFromView(any(), any(), nullable(), anyBoolean()) + verify(deviceItemInteractor).deviceItemFlow + } + } + + @Test + fun testShowDialog_withBluetoothStateValue() { + testScope.runTest { + bluetoothTileDialogViewModel.showDialog(context, null) + + assertThat(bluetoothTileDialogViewModel.dialog).isNotNull() + verify(bluetoothStateInteractor).updateBluetoothStateFlow } } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractorTest.kt index d04caaf3a1c9..df9914a8d012 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/tiles/dialog/bluetooth/DeviceItemInteractorTest.kt @@ -24,8 +24,13 @@ import android.testing.AndroidTestingRunner import android.testing.TestableLooper import androidx.test.filters.SmallTest import com.android.settingslib.bluetooth.CachedBluetoothDevice +import com.android.settingslib.bluetooth.LocalBluetoothManager import com.android.systemui.SysuiTestCase import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test @@ -60,11 +65,27 @@ class DeviceItemInteractorTest : SysuiTestCase() { @Mock private lateinit var adapter: BluetoothAdapter + @Mock private lateinit var localBluetoothManager: LocalBluetoothManager + private lateinit var interactor: DeviceItemInteractor + private lateinit var dispatcher: CoroutineDispatcher + + private lateinit var testScope: TestScope + @Before fun setUp() { - interactor = DeviceItemInteractor(bluetoothTileDialogRepository, audioManager, adapter) + dispatcher = StandardTestDispatcher() + testScope = TestScope(dispatcher) + interactor = + DeviceItemInteractor( + bluetoothTileDialogRepository, + audioManager, + adapter, + localBluetoothManager, + testScope.backgroundScope, + dispatcher + ) `when`(deviceItem1.cachedBluetoothDevice).thenReturn(cachedDevice1) `when`(deviceItem2.cachedBluetoothDevice).thenReturn(cachedDevice2) @@ -75,88 +96,109 @@ class DeviceItemInteractorTest : SysuiTestCase() { } @Test - fun testGetDeviceItems_noCachedDevice_returnEmpty() { - `when`(bluetoothTileDialogRepository.cachedDevices).thenReturn(emptyList()) - interactor.setDeviceItemFactoryListForTesting(listOf(createFactory({ true }, deviceItem1))) + fun testUpdateDeviceItems_noCachedDevice_returnEmpty() { + testScope.runTest { + `when`(bluetoothTileDialogRepository.cachedDevices).thenReturn(emptyList()) + interactor.setDeviceItemFactoryListForTesting( + listOf(createFactory({ true }, deviceItem1)) + ) - val deviceItems = interactor.getDeviceItems(mContext) + interactor.updateDeviceItems(mContext) - assertThat(deviceItems).isEmpty() + assertThat(interactor.deviceItemFlow.value).isEmpty() + } } @Test - fun testGetDeviceItems_hasCachedDevice_filterNotMatch_returnEmpty() { - `when`(bluetoothTileDialogRepository.cachedDevices).thenReturn(listOf(cachedDevice1)) - interactor.setDeviceItemFactoryListForTesting(listOf(createFactory({ false }, deviceItem1))) + fun testUpdateDeviceItems_hasCachedDevice_filterNotMatch_returnEmpty() { + testScope.runTest { + `when`(bluetoothTileDialogRepository.cachedDevices).thenReturn(listOf(cachedDevice1)) + interactor.setDeviceItemFactoryListForTesting( + listOf(createFactory({ false }, deviceItem1)) + ) - val deviceItems = interactor.getDeviceItems(mContext) + interactor.updateDeviceItems(mContext) - assertThat(deviceItems).isEmpty() + assertThat(interactor.deviceItemFlow.value).isEmpty() + } } @Test - fun testGetDeviceItems_hasCachedDevice_filterMatch_returnDeviceItem() { - `when`(bluetoothTileDialogRepository.cachedDevices).thenReturn(listOf(cachedDevice1)) - interactor.setDeviceItemFactoryListForTesting(listOf(createFactory({ true }, deviceItem1))) + fun testUpdateDeviceItems_hasCachedDevice_filterMatch_returnDeviceItem() { + testScope.runTest { + `when`(bluetoothTileDialogRepository.cachedDevices).thenReturn(listOf(cachedDevice1)) + interactor.setDeviceItemFactoryListForTesting( + listOf(createFactory({ true }, deviceItem1)) + ) - val deviceItems = interactor.getDeviceItems(mContext) + interactor.updateDeviceItems(mContext) - assertThat(deviceItems).hasSize(1) - assertThat(deviceItems[0]).isEqualTo(deviceItem1) + assertThat(interactor.deviceItemFlow.value).hasSize(1) + assertThat(interactor.deviceItemFlow.value!![0]).isEqualTo(deviceItem1) + } } @Test - fun testGetDeviceItems_hasCachedDevice_filterMatch_returnMultipleDeviceItem() { - `when`(adapter.mostRecentlyConnectedDevices).thenReturn(null) - interactor.setDeviceItemFactoryListForTesting( - listOf(createFactory({ false }, deviceItem1), createFactory({ true }, deviceItem2)) - ) + fun testUpdateDeviceItems_hasCachedDevice_filterMatch_returnMultipleDeviceItem() { + testScope.runTest { + `when`(adapter.mostRecentlyConnectedDevices).thenReturn(null) + interactor.setDeviceItemFactoryListForTesting( + listOf(createFactory({ false }, deviceItem1), createFactory({ true }, deviceItem2)) + ) - val deviceItems = interactor.getDeviceItems(mContext) + interactor.updateDeviceItems(mContext) - assertThat(deviceItems).hasSize(2) - assertThat(deviceItems[0]).isEqualTo(deviceItem2) - assertThat(deviceItems[1]).isEqualTo(deviceItem2) + assertThat(interactor.deviceItemFlow.value).hasSize(2) + assertThat(interactor.deviceItemFlow.value!![0]).isEqualTo(deviceItem2) + assertThat(interactor.deviceItemFlow.value!![1]).isEqualTo(deviceItem2) + } } @Test - fun testGetDeviceItems_sortByDisplayPriority() { - `when`(adapter.mostRecentlyConnectedDevices).thenReturn(null) - interactor.setDeviceItemFactoryListForTesting( - listOf( - createFactory({ cachedDevice -> cachedDevice.device == device1 }, deviceItem1), - createFactory({ cachedDevice -> cachedDevice.device == device2 }, deviceItem2) + fun testUpdateDeviceItems_sortByDisplayPriority() { + testScope.runTest { + `when`(adapter.mostRecentlyConnectedDevices).thenReturn(null) + interactor.setDeviceItemFactoryListForTesting( + listOf( + createFactory({ cachedDevice -> cachedDevice.device == device1 }, deviceItem1), + createFactory({ cachedDevice -> cachedDevice.device == device2 }, deviceItem2) + ) ) - ) - interactor.setDisplayPriorityForTesting( - listOf(DeviceItemType.SAVED_BLUETOOTH_DEVICE, DeviceItemType.CONNECTED_BLUETOOTH_DEVICE) - ) - `when`(deviceItem1.type).thenReturn(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE) - `when`(deviceItem2.type).thenReturn(DeviceItemType.SAVED_BLUETOOTH_DEVICE) + interactor.setDisplayPriorityForTesting( + listOf( + DeviceItemType.SAVED_BLUETOOTH_DEVICE, + DeviceItemType.CONNECTED_BLUETOOTH_DEVICE + ) + ) + `when`(deviceItem1.type).thenReturn(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE) + `when`(deviceItem2.type).thenReturn(DeviceItemType.SAVED_BLUETOOTH_DEVICE) - val deviceItems = interactor.getDeviceItems(mContext) + interactor.updateDeviceItems(mContext) - assertThat(deviceItems).isEqualTo(listOf(deviceItem2, deviceItem1)) + assertThat(interactor.deviceItemFlow.value).isEqualTo(listOf(deviceItem2, deviceItem1)) + } } @Test - fun testGetDeviceItems_sameType_sortByRecentlyConnected() { - `when`(adapter.mostRecentlyConnectedDevices).thenReturn(listOf(device2, device1)) - interactor.setDeviceItemFactoryListForTesting( - listOf( - createFactory({ cachedDevice -> cachedDevice.device == device1 }, deviceItem1), - createFactory({ cachedDevice -> cachedDevice.device == device2 }, deviceItem2) + fun testUpdateDeviceItems_sameType_sortByRecentlyConnected() { + testScope.runTest { + `when`(adapter.mostRecentlyConnectedDevices).thenReturn(listOf(device2, device1)) + interactor.setDeviceItemFactoryListForTesting( + listOf( + createFactory({ cachedDevice -> cachedDevice.device == device1 }, deviceItem1), + createFactory({ cachedDevice -> cachedDevice.device == device2 }, deviceItem2) + ) ) - ) - interactor.setDisplayPriorityForTesting( - listOf(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE) - ) - `when`(deviceItem1.type).thenReturn(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE) - `when`(deviceItem2.type).thenReturn(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE) + interactor.setDisplayPriorityForTesting( + listOf(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE) + ) + `when`(deviceItem1.type).thenReturn(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE) + `when`(deviceItem2.type).thenReturn(DeviceItemType.CONNECTED_BLUETOOTH_DEVICE) - val deviceItems = interactor.getDeviceItems(mContext) + interactor.updateDeviceItems(mContext) - assertThat(deviceItems).isEqualTo(listOf(deviceItem2, deviceItem1)) + assertThat(interactor.deviceItemFlow.value).isEqualTo(listOf(deviceItem2, deviceItem1)) + } } private fun createFactory( |