diff --git a/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java b/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java index 0690186b972..442acd2dd3f 100644 --- a/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java +++ b/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java @@ -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); diff --git a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java index ccf38ed2835..bd762a1ef11 100644 --- a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java +++ b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java @@ -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 void getController(Class clazz, + Consumer 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 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 createPreferenceControllers(Context context) { + if (Flags.enableBluetoothDeviceDetailsPolish()) { + mFormatter = + FeatureFactory.getFeatureFactory() + .getBluetoothFeatureProvider() + .getDeviceDetailsFragmentFormatter( + requireContext(), this, mBluetoothAdapter, mCachedDevice); + } ArrayList controllers = new ArrayList<>(); if (mCachedDevice != null) { diff --git a/src/com/android/settings/bluetooth/BluetoothFeatureProvider.java b/src/com/android/settings/bluetooth/BluetoothFeatureProvider.java index 1751082a45f..594134483f0 100644 --- a/src/com/android/settings/bluetooth/BluetoothFeatureProvider.java +++ b/src/com/android/settings/bluetooth/BluetoothFeatureProvider.java @@ -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 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); } diff --git a/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.java b/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.java index 2d4ac496d49..ae6e740998a 100644 --- a/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.java +++ b/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.java @@ -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); + } } diff --git a/src/com/android/settings/bluetooth/ui/MultiTogglePreferenceGroup.kt b/src/com/android/settings/bluetooth/ui/composable/MultiTogglePreferenceGroup.kt similarity index 99% rename from src/com/android/settings/bluetooth/ui/MultiTogglePreferenceGroup.kt rename to src/com/android/settings/bluetooth/ui/composable/MultiTogglePreferenceGroup.kt index e4ca00d47a9..b42e7d0cf72 100644 --- a/src/com/android/settings/bluetooth/ui/MultiTogglePreferenceGroup.kt +++ b/src/com/android/settings/bluetooth/ui/composable/MultiTogglePreferenceGroup.kt @@ -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 diff --git a/src/com/android/settings/bluetooth/ui/layout/DeviceSettingLayout.kt b/src/com/android/settings/bluetooth/ui/layout/DeviceSettingLayout.kt new file mode 100644 index 00000000000..87e2e8b4962 --- /dev/null +++ b/src/com/android/settings/bluetooth/ui/layout/DeviceSettingLayout.kt @@ -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) + +/** Represent a row in the layout. */ +data class DeviceSettingLayoutRow(val settingIds: Flow>) diff --git a/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt b/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt new file mode 100644 index 00000000000..3b77aae5b03 --- /dev/null +++ b/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt @@ -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? + + /** 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? = runBlocking { + viewModel.getItems()?.filterIsInstance()?.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() + .associateBy({ it.preferenceKey }, { it.settingId }) + + val settingIdToXmlPreferences: MutableMap = 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()) + } 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()) + } + } + } + + @Composable + private fun buildMultiTogglePreference(prefs: List) { + 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" + } +} diff --git a/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt b/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt new file mode 100644 index 00000000000..1c4861462d5 --- /dev/null +++ b/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt @@ -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? = 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() + .associateBy({ it.settingId }, { getDeviceSetting(cachedDevice, it.settingId) }) + + val configDeviceSetting = + configItems.map { idToDeviceSetting[it.settingId] ?: flowOf(null) } + val positionToSettingIds = + combine(configDeviceSetting) { settings -> + val positionMapping = mutableMapOf>() + var multiToggleSettingIds: MutableList? = 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 create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return BluetoothDeviceDetailsViewModel(deviceSettingRepository, cachedDevice) as T + } + } + + companion object { + private const val TAG = "BluetoothDeviceDetailsViewModel" + } +} diff --git a/src/com/android/settings/bluetooth/utils/DeviceSettingUtils.kt b/src/com/android/settings/bluetooth/utils/DeviceSettingUtils.kt new file mode 100644 index 00000000000..1bb8f201272 --- /dev/null +++ b/src/com/android/settings/bluetooth/utils/DeviceSettingUtils.kt @@ -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) diff --git a/src/com/android/settings/slices/SlicePreferenceController.java b/src/com/android/settings/slices/SlicePreferenceController.java index 5e8fb26eeb2..2e835a03cde 100644 --- a/src/com/android/settings/slices/SlicePreferenceController.java +++ b/src/com/android/settings/slices/SlicePreferenceController.java @@ -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); diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragmentTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragmentTest.java index 50aa7719ccb..19d0eddd3a4 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragmentTest.java +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragmentTest.java @@ -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); diff --git a/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt b/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt new file mode 100644 index 00000000000..468a2f0b702 --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt @@ -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() + 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 { + val prefs = mutableListOf() + for (i in 0..() + 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> { + var latestLayout = MutableList(layout.rows.size) { emptyList() } + 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") + } +}