Merge "Rearrange bluetooth device details fragment according to config" into main

This commit is contained in:
Haijie Hong
2024-08-15 08:13:11 +00:00
committed by Android (Google) Code Review
13 changed files with 949 additions and 21 deletions

View File

@@ -101,7 +101,8 @@ public class BlockingPrefWithSliceController extends BasePreferenceController im
return mUri != null ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
public void setSliceUri(Uri uri) {
/** Sets Slice uri for the preference. */
public void setSliceUri(@Nullable Uri uri) {
mUri = uri;
mLiveData = SliceLiveData.fromUri(mContext, mUri, (int type, Throwable source) -> {
Log.w(TAG, "Slice may be null. uri = " + uri + ", error = " + type);

View File

@@ -43,10 +43,12 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.settings.R;
import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatter;
import com.android.settings.connecteddevice.stylus.StylusDevicesController;
import com.android.settings.core.SettingsUIDeviceConfig;
import com.android.settings.dashboard.RestrictedDashboardFragment;
@@ -60,9 +62,11 @@ import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.core.AbstractPreferenceController;
import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
import com.android.settingslib.core.lifecycle.Lifecycle;
import com.android.settingslib.core.lifecycle.LifecycleObserver;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment {
public static final String KEY_DEVICE_ADDRESS = "device_address";
@@ -98,6 +102,8 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
@VisibleForTesting
CachedBluetoothDevice mCachedDevice;
BluetoothAdapter mBluetoothAdapter;
@VisibleForTesting
DeviceDetailsFragmentFormatter mFormatter;
@Nullable
InputDevice mInputDevice;
@@ -214,18 +220,29 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
finish();
return;
}
use(AdvancedBluetoothDetailsHeaderController.class).init(mCachedDevice, this);
use(LeAudioBluetoothDetailsHeaderController.class).init(mCachedDevice, mManager, this);
use(KeyboardSettingsPreferenceController.class).init(mCachedDevice);
getController(
AdvancedBluetoothDetailsHeaderController.class,
controller -> controller.init(mCachedDevice, this));
getController(
LeAudioBluetoothDetailsHeaderController.class,
controller -> controller.init(mCachedDevice, mManager, this));
getController(
KeyboardSettingsPreferenceController.class,
controller -> controller.init(mCachedDevice));
final BluetoothFeatureProvider featureProvider =
FeatureFactory.getFeatureFactory().getBluetoothFeatureProvider();
final boolean sliceEnabled = DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SETTINGS_UI,
SettingsUIDeviceConfig.BT_SLICE_SETTINGS_ENABLED, true);
use(BlockingPrefWithSliceController.class).setSliceUri(sliceEnabled
? featureProvider.getBluetoothDeviceSettingsUri(mCachedDevice.getDevice())
: null);
getController(
BlockingPrefWithSliceController.class,
controller ->
controller.setSliceUri(
sliceEnabled
? featureProvider.getBluetoothDeviceSettingsUri(
mCachedDevice.getDevice())
: null));
mManager.getEventManager().registerCallback(mBluetoothCallback);
mBluetoothAdapter.addOnMetadataChangedListener(
@@ -257,21 +274,35 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
}
}
mExtraControlUriLoaded |= controlUri != null;
final SlicePreferenceController slicePreferenceController = use(
SlicePreferenceController.class);
slicePreferenceController.setSliceUri(sliceEnabled ? controlUri : null);
slicePreferenceController.onStart();
slicePreferenceController.displayPreference(getPreferenceScreen());
Uri finalControlUri = controlUri;
getController(SlicePreferenceController.class, controller -> {
controller.setSliceUri(sliceEnabled ? finalControlUri : null);
controller.onStart();
controller.displayPreference(getPreferenceScreen());
});
// Temporarily fix the issue that the page will be automatically scrolled to a wrong
// position when entering the page. This will make sure the bluetooth header is shown on top
// of the page.
use(LeAudioBluetoothDetailsHeaderController.class).displayPreference(
getPreferenceScreen());
use(AdvancedBluetoothDetailsHeaderController.class).displayPreference(
getPreferenceScreen());
use(BluetoothDetailsHeaderController.class).displayPreference(
getPreferenceScreen());
getController(
LeAudioBluetoothDetailsHeaderController.class,
controller -> controller.displayPreference(getPreferenceScreen()));
getController(
AdvancedBluetoothDetailsHeaderController.class,
controller -> controller.displayPreference(getPreferenceScreen()));
getController(
BluetoothDetailsHeaderController.class,
controller -> controller.displayPreference(getPreferenceScreen()));
}
protected <T extends AbstractPreferenceController> void getController(Class<T> clazz,
Consumer<T> action) {
T controller = use(clazz);
if (controller != null) {
action.accept(controller);
}
}
private final ViewTreeObserver.OnGlobalLayoutListener mOnGlobalLayoutListener =
@@ -308,6 +339,14 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
return view;
}
@Override
public void onCreatePreferences(@NonNull Bundle savedInstanceState, @NonNull String rootKey) {
super.onCreatePreferences(savedInstanceState, rootKey);
if (Flags.enableBluetoothDeviceDetailsPolish()) {
mFormatter.updateLayout();
}
}
@Override
public void onResume() {
super.onResume();
@@ -358,8 +397,30 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
return super.onOptionsItemSelected(menuItem);
}
@Override
protected void addPreferenceController(AbstractPreferenceController controller) {
if (Flags.enableBluetoothDeviceDetailsPolish()) {
List<String> keys = mFormatter.getVisiblePreferenceKeysForMainPage();
Lifecycle lifecycle = getSettingsLifecycle();
if (keys == null || keys.contains(controller.getPreferenceKey())) {
super.addPreferenceController(controller);
} else if (controller instanceof LifecycleObserver) {
lifecycle.removeObserver((LifecycleObserver) controller);
}
} else {
super.addPreferenceController(controller);
}
}
@Override
protected List<AbstractPreferenceController> createPreferenceControllers(Context context) {
if (Flags.enableBluetoothDeviceDetailsPolish()) {
mFormatter =
FeatureFactory.getFeatureFactory()
.getBluetoothFeatureProvider()
.getDeviceDetailsFragmentFormatter(
requireContext(), this, mBluetoothAdapter, mCachedDevice);
}
ArrayList<AbstractPreferenceController> controllers = new ArrayList<>();
if (mCachedDevice != null) {

View File

@@ -16,15 +16,21 @@
package com.android.settings.bluetooth;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.ComponentName;
import android.content.Context;
import android.media.Spatializer;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.lifecycle.LifecycleCoroutineScope;
import androidx.preference.Preference;
import com.android.settings.SettingsPreferenceFragment;
import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatter;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository;
import java.util.List;
import java.util.Set;
@@ -84,4 +90,19 @@ public interface BluetoothFeatureProvider {
*/
Set<String> getInvisibleProfilePreferenceKeys(
Context context, BluetoothDevice bluetoothDevice);
/** Gets DeviceSettingRepository. */
@NonNull
DeviceSettingRepository getDeviceSettingRepository(
@NonNull Context context,
@NonNull BluetoothAdapter bluetoothAdapter,
@NonNull LifecycleCoroutineScope scope);
/** Gets device details fragment layout formatter. */
@NonNull
DeviceDetailsFragmentFormatter getDeviceDetailsFragmentFormatter(
@NonNull Context context,
@NonNull SettingsPreferenceFragment fragment,
@NonNull BluetoothAdapter bluetoothAdapter,
@NonNull CachedBluetoothDevice cachedDevice);
}

View File

@@ -16,6 +16,9 @@
package com.android.settings.bluetooth;
import static com.android.settings.bluetooth.utils.DeviceSettingUtilsKt.createDeviceSettingRepository;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.ComponentName;
import android.content.Context;
@@ -23,10 +26,16 @@ import android.media.AudioManager;
import android.media.Spatializer;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.lifecycle.LifecycleCoroutineScope;
import androidx.preference.Preference;
import com.android.settings.SettingsPreferenceFragment;
import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatter;
import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatterImpl;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
@@ -73,4 +82,24 @@ public class BluetoothFeatureProviderImpl implements BluetoothFeatureProvider {
Context context, BluetoothDevice bluetoothDevice) {
return ImmutableSet.of();
}
@Override
@NonNull
public DeviceSettingRepository getDeviceSettingRepository(
@NonNull Context context,
@NonNull BluetoothAdapter bluetoothAdapter,
@NonNull LifecycleCoroutineScope scope) {
return createDeviceSettingRepository(context, bluetoothAdapter, scope);
}
@Override
@NonNull
public DeviceDetailsFragmentFormatter getDeviceDetailsFragmentFormatter(
@NonNull Context context,
@NonNull SettingsPreferenceFragment fragment,
@NonNull BluetoothAdapter bluetoothAdapter,
@NonNull CachedBluetoothDevice cachedDevice) {
return new DeviceDetailsFragmentFormatterImpl(
context, fragment, bluetoothAdapter, cachedDevice);
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.android.settings.bluetooth.ui
package com.android.settings.bluetooth.ui.composable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background

View File

@@ -0,0 +1,25 @@
/*
* 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.settings.bluetooth.ui.layout
import kotlinx.coroutines.flow.Flow
/** Represent the layout of device settings. */
data class DeviceSettingLayout(val rows: List<DeviceSettingLayoutRow>)
/** Represent a row in the layout. */
data class DeviceSettingLayoutRow(val settingIds: Flow<List<Int>>)

View File

@@ -0,0 +1,225 @@
/*
* 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.settings.bluetooth.ui.view
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.util.Log
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import com.android.settings.SettingsPreferenceFragment
import com.android.settings.bluetooth.ui.composable.MultiTogglePreferenceGroup
import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout
import com.android.settings.bluetooth.ui.viewmodel.BluetoothDeviceDetailsViewModel
import com.android.settings.overlay.FeatureFactory.Companion.featureFactory
import com.android.settings.spa.preference.ComposePreference
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigItemModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel
import com.android.settingslib.spa.framework.theme.SettingsDimension
import com.android.settingslib.spa.widget.preference.Preference as SpaPreference
import com.android.settingslib.spa.widget.preference.PreferenceModel
import com.android.settingslib.spa.widget.preference.SwitchPreference
import com.android.settingslib.spa.widget.preference.SwitchPreferenceModel
import com.android.settingslib.spa.widget.preference.TwoTargetSwitchPreference
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
/** Handles device details fragment layout according to config. */
interface DeviceDetailsFragmentFormatter {
/** Gets keys of visible preferences in built-in preference in xml. */
fun getVisiblePreferenceKeysForMainPage(): List<String>?
/** Updates device details fragment layout. */
fun updateLayout()
}
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceDetailsFragmentFormatterImpl(
private val context: Context,
private val fragment: SettingsPreferenceFragment,
bluetoothAdapter: BluetoothAdapter,
private val cachedDevice: CachedBluetoothDevice
) : DeviceDetailsFragmentFormatter {
private val repository =
featureFactory.bluetoothFeatureProvider.getDeviceSettingRepository(
context, bluetoothAdapter, fragment.lifecycleScope)
private val viewModel: BluetoothDeviceDetailsViewModel =
ViewModelProvider(
fragment,
BluetoothDeviceDetailsViewModel.Factory(
repository,
cachedDevice,
))
.get(BluetoothDeviceDetailsViewModel::class.java)
override fun getVisiblePreferenceKeysForMainPage(): List<String>? = runBlocking {
viewModel.getItems()?.filterIsInstance<DeviceSettingConfigItemModel.BuiltinItem>()?.map {
it.preferenceKey
}
}
/** Updates bluetooth device details fragment layout. */
override fun updateLayout() = runBlocking {
val items = viewModel.getItems() ?: return@runBlocking
val layout = viewModel.getLayout() ?: return@runBlocking
val prefKeyToSettingId =
items
.filterIsInstance<DeviceSettingConfigItemModel.BuiltinItem>()
.associateBy({ it.preferenceKey }, { it.settingId })
val settingIdToXmlPreferences: MutableMap<Int, Preference> = HashMap()
for (i in 0 until fragment.preferenceScreen.preferenceCount) {
val pref = fragment.preferenceScreen.getPreference(i)
prefKeyToSettingId[pref.key]?.let { id -> settingIdToXmlPreferences[id] = pref }
}
fragment.preferenceScreen.removeAll()
for (row in items.indices) {
val settingId = items[row].settingId
if (settingIdToXmlPreferences.containsKey(settingId)) {
fragment.preferenceScreen.addPreference(
settingIdToXmlPreferences[settingId]!!.apply { order = row })
} else {
val pref =
ComposePreference(context)
.apply {
key = getPreferenceKey(settingId)
order = row
}
.also { pref -> pref.setContent { buildPreference(layout, row) } }
fragment.preferenceScreen.addPreference(pref)
}
}
}
@Composable
private fun buildPreference(layout: DeviceSettingLayout, row: Int) {
val contents by
remember(row) {
layout.rows[row].settingIds.flatMapLatest { settingIds ->
if (settingIds.isEmpty()) {
flowOf(emptyList<DeviceSettingModel>())
} else {
combine(
settingIds.map { settingId ->
viewModel.getDeviceSetting(cachedDevice, settingId)
}) {
it.toList()
}
}
}
}
.collectAsStateWithLifecycle(initialValue = listOf())
val settings = contents
when (settings.size) {
0 -> {}
1 -> {
when (val setting = settings[0]) {
is DeviceSettingModel.ActionSwitchPreference -> {
buildActionSwitchPreference(setting)
}
is DeviceSettingModel.MultiTogglePreference -> {
buildMultiTogglePreference(listOf(setting))
}
null -> {}
else -> {
Log.w(TAG, "Unknown preference type ${setting.id}, skip.")
}
}
}
else -> {
if (!settings.all { it is DeviceSettingModel.MultiTogglePreference }) {
return
}
buildMultiTogglePreference(
settings.filterIsInstance<DeviceSettingModel.MultiTogglePreference>())
}
}
}
@Composable
private fun buildMultiTogglePreference(prefs: List<DeviceSettingModel.MultiTogglePreference>) {
MultiTogglePreferenceGroup(prefs)
}
@Composable
private fun buildActionSwitchPreference(model: DeviceSettingModel.ActionSwitchPreference) {
if (model.switchState != null) {
val switchPrefModel =
object : SwitchPreferenceModel {
override val title = model.title
override val summary = { model.summary ?: "" }
override val checked = { model.switchState?.checked }
override val onCheckedChange = { newChecked: Boolean ->
model.updateState?.invoke(
DeviceSettingStateModel.ActionSwitchPreferenceState(newChecked))
Unit
}
override val icon = @Composable { deviceSettingIcon(model) }
}
if (model.intent != null) {
TwoTargetSwitchPreference(switchPrefModel) { context.startActivity(model.intent) }
} else {
SwitchPreference(switchPrefModel)
}
} else {
SpaPreference(
object : PreferenceModel {
override val title = model.title
override val summary = { model.summary ?: "" }
override val onClick = {
model.intent?.let { context.startActivity(it) }
Unit
}
override val icon = @Composable { deviceSettingIcon(model) }
})
}
}
@Composable
private fun deviceSettingIcon(model: DeviceSettingModel.ActionSwitchPreference) {
model.icon?.let { bitmap ->
Icon(
bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(SettingsDimension.itemIconSize),
tint = LocalContentColor.current)
}
}
private fun getPreferenceKey(settingId: Int) = "DEVICE_SETTING_${settingId}"
companion object {
const val TAG = "DeviceDetailsFormatter"
}
}

View File

@@ -0,0 +1,107 @@
/*
* 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.settings.bluetooth.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout
import com.android.settings.bluetooth.ui.layout.DeviceSettingLayoutRow
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.bluetooth.devicesettings.DeviceSettingId
import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigItemModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class BluetoothDeviceDetailsViewModel(
private val deviceSettingRepository: DeviceSettingRepository,
private val cachedDevice: CachedBluetoothDevice,
) : ViewModel() {
private val items =
viewModelScope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
deviceSettingRepository.getDeviceSettingsConfig(cachedDevice)
}
suspend fun getItems(): List<DeviceSettingConfigItemModel>? = items.await()?.mainItems
fun getDeviceSetting(cachedDevice: CachedBluetoothDevice, @DeviceSettingId settingId: Int) =
deviceSettingRepository.getDeviceSetting(cachedDevice, settingId)
suspend fun getLayout(): DeviceSettingLayout? {
val configItems = getItems() ?: return null
val idToDeviceSetting =
configItems
.filterIsInstance<DeviceSettingConfigItemModel.AppProvidedItem>()
.associateBy({ it.settingId }, { getDeviceSetting(cachedDevice, it.settingId) })
val configDeviceSetting =
configItems.map { idToDeviceSetting[it.settingId] ?: flowOf(null) }
val positionToSettingIds =
combine(configDeviceSetting) { settings ->
val positionMapping = mutableMapOf<Int, List<Int>>()
var multiToggleSettingIds: MutableList<Int>? = null
for (i in settings.indices) {
val configItem = configItems[i]
val setting = settings[i]
val isXmlPreference = configItem is DeviceSettingConfigItemModel.BuiltinItem
if (!isXmlPreference && setting == null) {
continue
}
if (setting !is DeviceSettingModel.MultiTogglePreference) {
multiToggleSettingIds = null
positionMapping[i] = listOf(configItem.settingId)
continue
}
if (multiToggleSettingIds != null) {
multiToggleSettingIds.add(setting.id)
} else {
multiToggleSettingIds = mutableListOf(setting.id)
positionMapping[i] = multiToggleSettingIds
}
}
positionMapping
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), initialValue = mapOf())
return DeviceSettingLayout(
configItems.indices.map { idx ->
DeviceSettingLayoutRow(positionToSettingIds.map { it[idx] ?: emptyList() })
})
}
class Factory(
private val deviceSettingRepository: DeviceSettingRepository,
private val cachedDevice: CachedBluetoothDevice,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return BluetoothDeviceDetailsViewModel(deviceSettingRepository, cachedDevice) as T
}
}
companion object {
private const val TAG = "BluetoothDeviceDetailsViewModel"
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.settings.bluetooth.utils
import android.bluetooth.BluetoothAdapter
import android.content.Context
import androidx.lifecycle.LifecycleCoroutineScope
import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepositoryImpl
import kotlinx.coroutines.Dispatchers
fun createDeviceSettingRepository(
context: Context,
bluetoothAdapter: BluetoothAdapter,
coroutineScope: LifecycleCoroutineScope
) = DeviceSettingRepositoryImpl(context, bluetoothAdapter, coroutineScope, Dispatchers.IO)

View File

@@ -20,6 +20,7 @@ import android.content.Context;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
@@ -61,7 +62,8 @@ public class SlicePreferenceController extends BasePreferenceController implemen
return mUri != null ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
public void setSliceUri(Uri uri) {
/** Sets Slice uri for the preference. */
public void setSliceUri(@Nullable Uri uri) {
mUri = uri;
mLiveData = SliceLiveData.fromUri(mContext, mUri, (int type, Throwable source) -> {
Log.w(TAG, "Slice may be null. uri = " + uri + ", error = " + type);

View File

@@ -50,6 +50,7 @@ import androidx.fragment.app.FragmentTransaction;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatter;
import com.android.settings.testutils.FakeFeatureFactory;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
@@ -101,6 +102,8 @@ public class BluetoothDeviceDetailsFragmentTest {
private InputManager mInputManager;
@Mock
private CompanionDeviceManager mCompanionDeviceManager;
@Mock
private DeviceDetailsFragmentFormatter mFormatter;
@Before
public void setUp() {
@@ -111,7 +114,10 @@ public class BluetoothDeviceDetailsFragmentTest {
.getSystemService(CompanionDeviceManager.class);
when(mCompanionDeviceManager.getAllAssociations()).thenReturn(ImmutableList.of());
removeInputDeviceWithMatchingBluetoothAddress();
FakeFeatureFactory.setupForTest();
FakeFeatureFactory fakeFeatureFactory = FakeFeatureFactory.setupForTest();
when(fakeFeatureFactory.mBluetoothFeatureProvider.getDeviceDetailsFragmentFormatter(any(),
any(), any(), eq(mCachedDevice))).thenReturn(mFormatter);
when(mFormatter.getVisiblePreferenceKeysForMainPage()).thenReturn(null);
mFragment = setupFragment();
mFragment.onAttach(mContext);

View File

@@ -0,0 +1,236 @@
/*
* 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.settings.bluetooth.ui.view
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.graphics.Bitmap
import androidx.fragment.app.FragmentActivity
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import androidx.preference.PreferenceScreen
import androidx.test.core.app.ApplicationProvider
import com.android.settings.dashboard.DashboardFragment
import com.android.settings.testutils.FakeFeatureFactory
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.bluetooth.devicesettings.DeviceSettingId
import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigItemModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.ToggleModel
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.flowOf
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.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.shadows.ShadowLooper.shadowMainLooper
@RunWith(RobolectricTestRunner::class)
class DeviceDetailsFragmentFormatterTest {
@get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
@Mock private lateinit var cachedDevice: CachedBluetoothDevice
@Mock private lateinit var bluetoothAdapter: BluetoothAdapter
@Mock private lateinit var repository: DeviceSettingRepository
private lateinit var fragment: TestFragment
private lateinit var underTest: DeviceDetailsFragmentFormatter
private lateinit var featureFactory: FakeFeatureFactory
private val testScope = TestScope()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
featureFactory = FakeFeatureFactory.setupForTest()
`when`(
featureFactory.bluetoothFeatureProvider.getDeviceSettingRepository(
eq(context), eq(bluetoothAdapter), any()))
.thenReturn(repository)
val fragmentActivity = Robolectric.setupActivity(FragmentActivity::class.java)
assertThat(fragmentActivity.applicationContext).isNotNull()
fragment = TestFragment(context)
fragmentActivity.supportFragmentManager.beginTransaction().add(fragment, null).commit()
shadowMainLooper().idle()
fragment.preferenceScreen.run {
addPreference(Preference(context).apply { key = "bluetooth_device_header" })
addPreference(Preference(context).apply { key = "action_buttons" })
addPreference(Preference(context).apply { key = "keyboard_settings" })
}
underTest =
DeviceDetailsFragmentFormatterImpl(context, fragment, bluetoothAdapter, cachedDevice)
}
@Test
fun getVisiblePreferenceKeysForMainPage_hasConfig_returnList() {
testScope.runTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice))
.thenReturn(
DeviceSettingConfigModel(
listOf(
DeviceSettingConfigItemModel.BuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_HEADER,
"bluetooth_device_header"),
DeviceSettingConfigItemModel.BuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_ACTION_BUTTONS, "action_buttons"),
),
listOf(),
"footer"))
val keys = underTest.getVisiblePreferenceKeysForMainPage()
assertThat(keys).containsExactly("bluetooth_device_header", "action_buttons")
}
}
@Test
fun getVisiblePreferenceKeysForMainPage_noConfig_returnNull() {
testScope.runTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice)).thenReturn(null)
val keys = underTest.getVisiblePreferenceKeysForMainPage()
assertThat(keys).isNull()
}
}
@Test
fun updateLayout_configIsNull_notChange() {
testScope.runTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice)).thenReturn(null)
underTest.updateLayout()
assertThat(getDisplayedPreferences().map { it.key })
.containsExactly("bluetooth_device_header", "action_buttons", "keyboard_settings")
}
}
@Test
fun updateLayout_itemsNotInConfig_hide() {
testScope.runTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice))
.thenReturn(
DeviceSettingConfigModel(
listOf(
DeviceSettingConfigItemModel.BuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_HEADER,
"bluetooth_device_header"),
DeviceSettingConfigItemModel.BuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_KEYBOARD_SETTINGS,
"keyboard_settings"),
),
listOf(),
"footer"))
underTest.updateLayout()
assertThat(getDisplayedPreferences().map { it.key })
.containsExactly("bluetooth_device_header", "keyboard_settings")
}
}
@Test
fun updateLayout_newItems_displayNewItems() {
testScope.runTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice))
.thenReturn(
DeviceSettingConfigModel(
listOf(
DeviceSettingConfigItemModel.BuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_HEADER,
"bluetooth_device_header"),
DeviceSettingConfigItemModel.AppProvidedItem(
DeviceSettingId.DEVICE_SETTING_ID_ANC),
DeviceSettingConfigItemModel.BuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_KEYBOARD_SETTINGS,
"keyboard_settings"),
),
listOf(),
"footer"))
`when`(repository.getDeviceSetting(cachedDevice, DeviceSettingId.DEVICE_SETTING_ID_ANC))
.thenReturn(
flowOf(
DeviceSettingModel.MultiTogglePreference(
cachedDevice,
DeviceSettingId.DEVICE_SETTING_ID_ANC,
"title",
toggles =
listOf(
ToggleModel(
"", Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))),
isActive = true,
state = DeviceSettingStateModel.MultiTogglePreferenceState(0),
isAllowedChangingState = true,
updateState = {})))
underTest.updateLayout()
assertThat(getDisplayedPreferences().map { it.key })
.containsExactly(
"bluetooth_device_header",
"DEVICE_SETTING_${DeviceSettingId.DEVICE_SETTING_ID_ANC}",
"keyboard_settings")
}
}
private fun getDisplayedPreferences(): List<Preference> {
val prefs = mutableListOf<Preference>()
for (i in 0..<fragment.preferenceScreen.preferenceCount) {
prefs.add(fragment.preferenceScreen.getPreference(i))
}
return prefs
}
class TestFragment(context: Context) : DashboardFragment() {
private val mPreferenceManager: PreferenceManager = PreferenceManager(context)
init {
mPreferenceManager.setPreferences(mPreferenceManager.createPreferenceScreen(context))
}
public override fun getPreferenceScreenResId(): Int = 0
override fun getLogTag(): String = "TestLogTag"
override fun getPreferenceScreen(): PreferenceScreen {
return mPreferenceManager.preferenceScreen
}
override fun getMetricsCategory(): Int = 0
override fun getPreferenceManager(): PreferenceManager {
return mPreferenceManager
}
}
private companion object {}
}

View File

@@ -0,0 +1,186 @@
/*
* 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.settings.bluetooth.ui.viewmodel
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.graphics.Bitmap
import androidx.test.core.app.ApplicationProvider
import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout
import com.android.settings.testutils.FakeFeatureFactory
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.bluetooth.devicesettings.DeviceSettingId
import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigItemModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel
import com.android.settingslib.bluetooth.devicesettings.shared.model.ToggleModel
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
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.any
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.robolectric.RobolectricTestRunner
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class BluetoothDeviceDetailsViewModelTest {
@get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
@Mock private lateinit var cachedDevice: CachedBluetoothDevice
@Mock private lateinit var bluetoothAdapter: BluetoothAdapter
@Mock private lateinit var repository: DeviceSettingRepository
private lateinit var underTest: BluetoothDeviceDetailsViewModel
private lateinit var featureFactory: FakeFeatureFactory
private val testScope = TestScope()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
featureFactory = FakeFeatureFactory.setupForTest()
`when`(
featureFactory.bluetoothFeatureProvider.getDeviceSettingRepository(
eq(context), eq(bluetoothAdapter), any()))
.thenReturn(repository)
underTest = BluetoothDeviceDetailsViewModel(repository, cachedDevice)
}
@Test
fun getItems_returnConfigMainItems() {
testScope.runTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice))
.thenReturn(
DeviceSettingConfigModel(
listOf(BUILTIN_SETTING_ITEM_1, BUILDIN_SETTING_ITEM_2), listOf(), "footer"))
val keys = underTest.getItems()
assertThat(keys).containsExactly(BUILTIN_SETTING_ITEM_1, BUILDIN_SETTING_ITEM_2)
}
}
@Test
fun getLayout_builtinDeviceSettings() {
testScope.runTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice))
.thenReturn(
DeviceSettingConfigModel(
listOf(BUILTIN_SETTING_ITEM_1, BUILDIN_SETTING_ITEM_2), listOf(), "footer"))
val layout = underTest.getLayout()!!
assertThat(getLatestLayout(layout))
.isEqualTo(
listOf(
listOf(DeviceSettingId.DEVICE_SETTING_ID_HEADER),
listOf(DeviceSettingId.DEVICE_SETTING_ID_ACTION_BUTTONS)))
}
}
@Test
fun getLayout_remoteDeviceSettings() {
val remoteSettingId1 = 10001
val remoteSettingId2 = 10002
val remoteSettingId3 = 10003
testScope.runTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice))
.thenReturn(
DeviceSettingConfigModel(
listOf(
BUILTIN_SETTING_ITEM_1,
buildRemoteSettingItem(remoteSettingId1),
buildRemoteSettingItem(remoteSettingId2),
buildRemoteSettingItem(remoteSettingId3),
),
listOf(),
"footer"))
`when`(repository.getDeviceSetting(cachedDevice, remoteSettingId1))
.thenReturn(flowOf(buildMultiTogglePreference(remoteSettingId1)))
`when`(repository.getDeviceSetting(cachedDevice, remoteSettingId2))
.thenReturn(flowOf(buildMultiTogglePreference(remoteSettingId2)))
`when`(repository.getDeviceSetting(cachedDevice, remoteSettingId3))
.thenReturn(flowOf(buildActionSwitchPreference(remoteSettingId3)))
val layout = underTest.getLayout()!!
assertThat(getLatestLayout(layout))
.isEqualTo(
listOf(
listOf(DeviceSettingId.DEVICE_SETTING_ID_HEADER),
listOf(remoteSettingId1, remoteSettingId2),
listOf(remoteSettingId3),
))
}
}
private fun getLatestLayout(layout: DeviceSettingLayout): List<List<Int>> {
var latestLayout = MutableList(layout.rows.size) { emptyList<Int>() }
for (i in layout.rows.indices) {
layout.rows[i]
.settingIds
.onEach { latestLayout[i] = it }
.launchIn(testScope.backgroundScope)
}
testScope.runCurrent()
return latestLayout.filter { !it.isEmpty() }.toList()
}
private fun buildMultiTogglePreference(settingId: Int) =
DeviceSettingModel.MultiTogglePreference(
cachedDevice,
settingId,
"title",
toggles = listOf(ToggleModel("", Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))),
isActive = true,
state = DeviceSettingStateModel.MultiTogglePreferenceState(0),
isAllowedChangingState = true,
updateState = {})
private fun buildActionSwitchPreference(settingId: Int) =
DeviceSettingModel.ActionSwitchPreference(cachedDevice, settingId, "title")
private fun buildRemoteSettingItem(settingId: Int) =
DeviceSettingConfigItemModel.AppProvidedItem(settingId)
private companion object {
val BUILTIN_SETTING_ITEM_1 =
DeviceSettingConfigItemModel.BuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_HEADER, "bluetooth_device_header")
val BUILDIN_SETTING_ITEM_2 =
DeviceSettingConfigItemModel.BuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_ACTION_BUTTONS, "action_buttons")
}
}