Reimplement device details UI without ComposePreference

Replace ComposePreference with androidx.preference.Preference.

ANC toggle will still use Compose until SegmentedButtonPreference is ready.

BUG: 402036473
Test: local tested
Flag: com.android.settings.flags.enable_bluetooth_device_details_polish
Change-Id: I5114af8f2d679d695b3c5ef4d7be2874245c435e
This commit is contained in:
Haijie Hong
2025-03-10 18:32:22 +08:00
parent 8a333df6fa
commit 02bd645834
7 changed files with 522 additions and 411 deletions

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2025 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.
-->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?android:colorControlHighlight">
<item>
<shape android:shape="rectangle">
<solid
android:color="@color/settingslib_materialColorSecondaryContainer" />
<corners
android:radius="28dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2025 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.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingEnd="?android:attr/scrollbarSize"
android:background="?android:attr/selectableItemBackground">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginHorizontal="16dp"
android:background="@drawable/device_details_spotlight_preference_background" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/listPreferredItemHeight"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:gravity="center_vertical">
<ImageView
android:id="@+android:id/icon"
android:layout_width="24dip"
android:layout_height="24dip"
android:layout_gravity="center"
android:scaleType="center"
android:importantForAccessibility="no" />
<RelativeLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="6dp"
android:layout_marginTop="6dp"
android:layout_marginBottom="6dp"
android:layout_weight="1">
<TextView android:id="@+android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceLarge"
android:ellipsize="marquee"
android:fadingEdge="horizontal" />
<TextView android:id="@+android:id/summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
android:layout_alignStart="@android:id/title"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="?android:attr/textColorSecondary"
android:maxLines="4" />
</RelativeLayout>
</LinearLayout>
</FrameLayout>

View File

@@ -1,28 +0,0 @@
/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.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 columns: Flow<List<DeviceSettingLayoutColumn>>)
/** Represent a column in a row. */
data class DeviceSettingLayoutColumn(val settingId: Int, val highlighted: Boolean)

View File

@@ -21,35 +21,26 @@ import android.app.settings.SettingsEnums
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Bundle
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
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.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toDrawable
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import androidx.preference.SwitchPreferenceCompat
import androidx.preference.TwoStatePreference
import com.android.settings.R
import com.android.settings.bluetooth.BlockingPrefWithSliceController
import com.android.settings.bluetooth.BluetoothDetailsProfilesController
import com.android.settings.bluetooth.ui.composable.Icon
import com.android.settings.bluetooth.ui.composable.MultiTogglePreference
import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout
import com.android.settings.bluetooth.ui.model.DeviceSettingPreferenceModel
import com.android.settings.bluetooth.ui.model.FragmentTypeModel
import com.android.settings.bluetooth.ui.view.DeviceDetailsMoreSettingsFragment.Companion.KEY_DEVICE_ADDRESS
@@ -58,6 +49,7 @@ import com.android.settings.core.SubSettingLauncher
import com.android.settings.dashboard.DashboardFragment
import com.android.settings.overlay.FeatureFactory
import com.android.settings.spa.preference.ComposePreference
import com.android.settingslib.PrimarySwitchPreference
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.bluetooth.devicesettings.DeviceSettingId
import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingActionModel
@@ -67,24 +59,15 @@ import com.android.settingslib.core.AbstractPreferenceController
import com.android.settingslib.core.lifecycle.LifecycleObserver
import com.android.settingslib.core.lifecycle.events.OnPause
import com.android.settingslib.core.lifecycle.events.OnStop
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 com.android.settingslib.spa.widget.ui.Footer
import com.android.settingslib.widget.FooterPreference
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@@ -105,7 +88,7 @@ interface DeviceDetailsFragmentFormatter {
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceDetailsFragmentFormatterImpl(
private val context: Context,
private val fragment: DashboardFragment,
private val dashboardFragment: DashboardFragment,
controllers: List<AbstractPreferenceController>,
private val bluetoothAdapter: BluetoothAdapter,
private val cachedDevice: CachedBluetoothDevice,
@@ -120,32 +103,30 @@ class DeviceDetailsFragmentFormatterImpl(
private val viewModel: BluetoothDeviceDetailsViewModel =
ViewModelProvider(
fragment,
BluetoothDeviceDetailsViewModel.Factory(
fragment.requireActivity().application,
bluetoothAdapter,
cachedDevice,
backgroundCoroutineContext,
),
)
dashboardFragment,
BluetoothDeviceDetailsViewModel.Factory(
dashboardFragment.requireActivity().application,
bluetoothAdapter,
cachedDevice,
backgroundCoroutineContext,
),
)
.get(BluetoothDeviceDetailsViewModel::class.java)
/** Updates bluetooth device details fragment layout. */
override fun updateLayout(fragmentType: FragmentTypeModel) {
fragment.setLoading(true, false)
dashboardFragment.setLoading(true, false)
isLoading = true
fragment.lifecycleScope.launch { updateLayoutInternal(fragmentType) }
dashboardFragment.lifecycleScope.launch { updateLayoutInternal(fragmentType) }
}
private suspend fun updateLayoutInternal(fragmentType: FragmentTypeModel) {
val items = viewModel.getItems(fragmentType) ?: run {
fragment.setLoading(false, false)
return
}
val layout = viewModel.getLayout(fragmentType) ?: run {
fragment.setLoading(false, false)
return
}
val items =
viewModel.getItems(fragmentType)
?: run {
dashboardFragment.setLoading(false, false)
return
}
val prefKeyToSettingId =
items
@@ -153,14 +134,14 @@ class DeviceDetailsFragmentFormatterImpl(
.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)
for (i in 0 until dashboardFragment.preferenceScreen.preferenceCount) {
val pref = dashboardFragment.preferenceScreen.getPreference(i)
prefKeyToSettingId[pref.key]?.let { id -> settingIdToXmlPreferences[id] = pref }
if (pref.key !in prefKeyToSettingId) {
getController(pref.key)?.let { disableController(it) }
}
}
fragment.preferenceScreen.removeAll()
dashboardFragment.preferenceScreen.removeAll()
for (job in prefVisibilityJobs) {
job.cancel()
}
@@ -170,53 +151,83 @@ class DeviceDetailsFragmentFormatterImpl(
val settingId = settingItem.settingId
if (settingIdToXmlPreferences.containsKey(settingId)) {
val pref = settingIdToXmlPreferences[settingId]!!.apply { order = row }
fragment.preferenceScreen.addPreference(pref)
dashboardFragment.preferenceScreen.addPreference(pref)
} else {
val prefKey = getPreferenceKey(settingId)
prefVisibilityJobs.add(
getDevicesSettingForRow(layout, row)
.onEach { logItemShown(prefKey, it.isNotEmpty()) }
.launchIn(fragment.lifecycleScope)
viewModel
.getDeviceSetting(cachedDevice, settingId)
.onEach { logItemShown(prefKey, it != null) }
.launchIn(dashboardFragment.lifecycleScope)
)
val pref =
ComposePreference(context)
.apply {
key = prefKey
order = row
if (settingId == DeviceSettingId.DEVICE_SETTING_ID_ANC) {
// TODO(b/399316980): replace it with SegmentedButtonPreference once it's ready.
val pref =
ComposePreference(context)
.apply {
key = prefKey
order = row
}
.also { pref ->
pref.setContent {
buildComposePreference(cachedDevice, settingId, prefKey)
}
}
dashboardFragment.preferenceScreen.addPreference(pref)
} else {
viewModel
.getDeviceSetting(cachedDevice, settingId)
.onEach {
val existedPref =
dashboardFragment.preferenceScreen.findPreference<Preference>(
prefKey
)
val item =
it
?: run {
existedPref?.let {
dashboardFragment.preferenceScreen.removePreference(
existedPref
)
}
return@onEach
}
buildPreference(existedPref, item, prefKey, settingItem.highlighted)
?.apply {
key = prefKey
order = row
}
?.also { dashboardFragment.preferenceScreen.addPreference(it) }
}
.also { pref -> pref.setContent { buildPreference(layout, row, prefKey) } }
fragment.preferenceScreen.addPreference(pref)
.launchIn(dashboardFragment.lifecycleScope)
}
}
}
// TODO(b/343317785): figure out how to remove the foot preference.
fragment.preferenceScreen.addPreference(ComposePreference(context).apply {
order = 10000
isEnabled = false
isSelectable = false
setContent { Spacer(modifier = Modifier.height(1.dp)) }
})
for (row in items.indices) {
val settingItem = items[row]
val settingId = settingItem.settingId
if (settingIdToXmlPreferences.containsKey(settingId)) {
val pref = fragment.preferenceScreen.getPreference(row)
settingIdToXmlPreferences[settingId]?.let { pref ->
if (settingId == DeviceSettingId.DEVICE_SETTING_ID_BLUETOOTH_PROFILES) {
(getController(pref.key) as? BluetoothDetailsProfilesController)?.run {
if (settingItem is DeviceSettingConfigItemModel.BuiltinItem.BluetoothProfilesItem) {
if (
settingItem
is DeviceSettingConfigItemModel.BuiltinItem.BluetoothProfilesItem
) {
setInvisibleProfiles(settingItem.invisibleProfiles)
setHasExtraSpace(false)
}
}
}
getController(pref.key)?.displayPreference(fragment.preferenceScreen)
getController(pref.key)?.displayPreference(dashboardFragment.preferenceScreen)
logItemShown(pref.key, pref.isVisible)
}
}
fragment.lifecycleScope.launch {
dashboardFragment.lifecycleScope.launch {
if (isLoading) {
fragment.setLoading(false, false)
dashboardFragment.setLoading(false, false)
isLoading = false
}
}
@@ -236,87 +247,170 @@ class DeviceDetailsFragmentFormatterImpl(
} ?: emit(null)
}
private fun getDevicesSettingForRow(
layout: DeviceSettingLayout,
row: Int,
): Flow<List<DeviceSettingPreferenceModel>> =
layout.rows[row].columns.flatMapLatest { columns ->
if (columns.isEmpty()) {
flowOf(emptyList())
} else {
combine(
columns.map { column ->
viewModel.getDeviceSetting(cachedDevice, column.settingId)
}
) {
it.toList().filterNotNull()
private fun buildPreference(
existedPref: Preference?,
model: DeviceSettingPreferenceModel,
prefKey: String,
highlighted: Boolean,
): Preference? =
when (model) {
is DeviceSettingPreferenceModel.PlainPreference -> {
val pref =
existedPref
?: run {
if (highlighted) SpotlightPreference(context) else Preference(context)
}
pref.apply {
title = model.title
summary = model.summary
icon = getDrawable(model.icon)
onPreferenceClickListener =
object : Preference.OnPreferenceClickListener {
override fun onPreferenceClick(p: Preference): Boolean {
logItemClick(prefKey, EVENT_CLICK_PRIMARY)
model.action?.let { triggerAction(it) }
return true
}
}
}
}
is DeviceSettingPreferenceModel.SwitchPreference ->
if (model.action == null) {
val pref =
existedPref as? SwitchPreferenceCompat ?: SwitchPreferenceCompat(context)
pref.apply {
title = model.title
summary = model.summary
icon = getDrawable(model.icon)
isChecked = model.checked
isEnabled = !model.disabled
onPreferenceChangeListener =
object : Preference.OnPreferenceChangeListener {
override fun onPreferenceChange(
p: Preference,
value: Any?,
): Boolean {
(p as? TwoStatePreference)?.let { newState ->
val newState = value as? Boolean ?: return false
logItemClick(
prefKey,
if (newState) EVENT_SWITCH_ON else EVENT_SWITCH_OFF,
)
isEnabled = false
model.onCheckedChange.invoke(newState)
}
return false
}
}
}
} else {
val pref =
existedPref as? PrimarySwitchPreference ?: PrimarySwitchPreference(context)
pref.apply {
title = model.title
summary = model.summary
icon = getDrawable(model.icon)
isChecked = model.checked
isEnabled = !model.disabled
isSwitchEnabled = !model.disabled
onPreferenceClickListener =
object : Preference.OnPreferenceClickListener {
override fun onPreferenceClick(p: Preference): Boolean {
logItemClick(prefKey, EVENT_CLICK_PRIMARY)
triggerAction(model.action)
return true
}
}
onPreferenceChangeListener =
object : Preference.OnPreferenceChangeListener {
override fun onPreferenceChange(
p: Preference,
value: Any?,
): Boolean {
val newState = value as? Boolean ?: return false
logItemClick(
prefKey,
if (newState) EVENT_SWITCH_ON else EVENT_SWITCH_OFF,
)
isSwitchEnabled = false
model.onCheckedChange.invoke(newState)
return false
}
}
}
}
is DeviceSettingPreferenceModel.MultiTogglePreference -> {
// TODO(b/399316980): implemented it by SegmentedButtonPreference
null
}
is DeviceSettingPreferenceModel.FooterPreference -> {
val pref = existedPref as? FooterPreference ?: FooterPreference(context)
pref.apply { title = model.footerText }
}
is DeviceSettingPreferenceModel.MoreSettingsPreference -> {
val pref = existedPref ?: Preference(context)
pref.apply {
title =
context.getString(R.string.bluetooth_device_more_settings_preference_title)
summary =
context.getString(
R.string.bluetooth_device_more_settings_preference_summary
)
icon = context.getDrawable(R.drawable.ic_chevron_right_24dp)
onPreferenceClickListener =
object : Preference.OnPreferenceClickListener {
override fun onPreferenceClick(p: Preference): Boolean {
logItemClick(prefKey, EVENT_CLICK_PRIMARY)
SubSettingLauncher(context)
.setDestination(
DeviceDetailsMoreSettingsFragment::class.java.name
)
.setSourceMetricsCategory(
dashboardFragment.getMetricsCategory()
)
.setArguments(
Bundle().apply {
putString(KEY_DEVICE_ADDRESS, cachedDevice.address)
}
)
.launch()
return true
}
}
}
}
is DeviceSettingPreferenceModel.HelpPreference -> {
null
}
}
@Composable
private fun buildPreference(layout: DeviceSettingLayout, row: Int, prefKey: String) {
val contents by
remember(row) { getDevicesSettingForRow(layout, row) }
.collectAsStateWithLifecycle(initialValue = listOf())
val highlighted by
remember(row) {
layout.rows[row].columns.map { columns -> columns.any { it.highlighted } }
private fun getDrawable(deviceSettingIcon: DeviceSettingIcon?): Drawable? =
when (deviceSettingIcon) {
is DeviceSettingIcon.BitmapIcon ->
deviceSettingIcon.bitmap.toDrawable(context.resources)
is DeviceSettingIcon.ResourceIcon -> context.getDrawable(deviceSettingIcon.resId)
null -> null
}
.collectAsStateWithLifecycle(initialValue = false)
@Composable
private fun buildComposePreference(
cachedDevice: CachedBluetoothDevice,
settingId: Int,
prefKey: String,
) {
val contents by
remember(settingId) { viewModel.getDeviceSetting(cachedDevice, settingId) }
.collectAsStateWithLifecycle(initialValue = null)
val settings = contents
AnimatedVisibility(visible = settings.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) {
Box {
Box(
modifier =
Modifier.matchParentSize()
.padding(16.dp, 0.dp, 8.dp, 0.dp)
.background(
color =
if (highlighted) {
MaterialTheme.colorScheme.primaryContainer
} else {
Color.Transparent
},
shape = RoundedCornerShape(28.dp),
)
) {}
buildPreferences(settings, prefKey)
AnimatedVisibility(visible = settings != null, enter = fadeIn(), exit = fadeOut()) {
(settings as? DeviceSettingPreferenceModel.MultiTogglePreference)?.let {
buildMultiTogglePreference(it, prefKey)
}
}
}
@Composable
fun buildPreferences(settings: List<DeviceSettingPreferenceModel?>, prefKey: String) {
when (settings.size) {
0 -> {}
1 -> {
when (val setting = settings[0]) {
is DeviceSettingPreferenceModel.PlainPreference -> {
buildPlainPreference(setting, prefKey)
}
is DeviceSettingPreferenceModel.SwitchPreference -> {
buildSwitchPreference(setting, prefKey)
}
is DeviceSettingPreferenceModel.MultiTogglePreference -> {
buildMultiTogglePreference(setting, prefKey)
}
is DeviceSettingPreferenceModel.FooterPreference -> {
buildFooterPreference(setting)
}
is DeviceSettingPreferenceModel.MoreSettingsPreference -> {
buildMoreSettingsPreference(prefKey)
}
is DeviceSettingPreferenceModel.HelpPreference -> {}
null -> {}
}
}
else -> {}
}
}
@Composable
private fun buildMultiTogglePreference(
pref: DeviceSettingPreferenceModel.MultiTogglePreference,
@@ -332,107 +426,6 @@ class DeviceDetailsFragmentFormatterImpl(
)
}
@Composable
private fun buildSwitchPreference(
model: DeviceSettingPreferenceModel.SwitchPreference,
prefKey: String,
) {
val switchPrefModel =
object : SwitchPreferenceModel {
override val title = model.title
override val summary = { model.summary ?: "" }
override val checked = { model.checked }
override val onCheckedChange = { newState: Boolean ->
logItemClick(prefKey, if (newState) EVENT_SWITCH_ON else EVENT_SWITCH_OFF)
model.onCheckedChange(newState)
}
override val changeable = { !model.disabled }
override val icon: (@Composable () -> Unit)?
get() {
if (model.icon == null) {
return null
}
return { deviceSettingIcon(model.icon) }
}
}
if (model.action != null) {
TwoTargetSwitchPreference(
switchPrefModel,
primaryOnClick = {
logItemClick(prefKey, EVENT_CLICK_PRIMARY)
triggerAction(model.action)
},
primaryEnabled = { !model.disabled },
)
} else {
SwitchPreference(switchPrefModel)
}
}
@Composable
private fun buildPlainPreference(
model: DeviceSettingPreferenceModel.PlainPreference,
prefKey: String,
) {
SpaPreference(
object : PreferenceModel {
override val title = model.title
override val summary = { model.summary ?: "" }
override val onClick = {
logItemClick(prefKey, EVENT_CLICK_PRIMARY)
model.action?.let { triggerAction(it) }
Unit
}
override val icon: (@Composable () -> Unit)?
get() {
if (model.icon == null) {
return null
}
return { deviceSettingIcon(model.icon) }
}
}
)
}
@Composable
fun buildMoreSettingsPreference(prefKey: String) {
SpaPreference(
object : PreferenceModel {
override val title =
stringResource(R.string.bluetooth_device_more_settings_preference_title)
override val summary = {
context.getString(R.string.bluetooth_device_more_settings_preference_summary)
}
override val onClick = {
logItemClick(prefKey, EVENT_CLICK_PRIMARY)
SubSettingLauncher(context)
.setDestination(DeviceDetailsMoreSettingsFragment::class.java.name)
.setSourceMetricsCategory(fragment.getMetricsCategory())
.setArguments(
Bundle().apply { putString(KEY_DEVICE_ADDRESS, cachedDevice.address) }
)
.launch()
}
override val icon =
@Composable {
deviceSettingIcon(
DeviceSettingIcon.ResourceIcon(R.drawable.ic_chevron_right_24dp)
)
}
}
)
}
@Composable
fun buildFooterPreference(model: DeviceSettingPreferenceModel.FooterPreference) {
Footer(footerText = model.footerText)
}
@Composable
private fun deviceSettingIcon(icon: DeviceSettingIcon?) {
icon?.let { Icon(it, modifier = Modifier.size(SettingsDimension.itemIconSize)) }
}
private fun logItemClick(preferenceKey: String, value: Int = 0) {
logAction(preferenceKey, SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_ITEM_CLICKED, value)
}
@@ -452,7 +445,7 @@ class DeviceDetailsFragmentFormatterImpl(
if (it) EVENT_VISIBLE else EVENT_INVISIBLE,
)
}
.launchIn(fragment.lifecycleScope)
.launchIn(dashboardFragment.lifecycleScope)
}
}
.value = visible
@@ -485,7 +478,7 @@ class DeviceDetailsFragmentFormatterImpl(
private fun disableController(controller: AbstractPreferenceController) {
if (controller is LifecycleObserver) {
fragment.settingsLifecycle.removeObserver(controller as LifecycleObserver)
dashboardFragment.settingsLifecycle.removeObserver(controller as LifecycleObserver)
}
if (controller is BlockingPrefWithSliceController) {
@@ -504,6 +497,19 @@ class DeviceDetailsFragmentFormatterImpl(
private fun getPreferenceKey(settingId: Int) = "DEVICE_SETTING_${settingId}"
private class SpotlightPreference(context: Context) : Preference(context) {
init {
layoutResource = R.layout.bluetooth_device_spotlight_preference
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
holder.isDividerAllowedBelow = false
holder.isDividerAllowedAbove = false
}
}
private companion object {
const val TAG = "DeviceDetailsFormatter"
const val EVENT_SWITCH_OFF = 0

View File

@@ -23,9 +23,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.android.settings.R
import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout
import com.android.settings.bluetooth.ui.layout.DeviceSettingLayoutColumn
import com.android.settings.bluetooth.ui.layout.DeviceSettingLayoutRow
import com.android.settings.bluetooth.ui.model.DeviceSettingPreferenceModel
import com.android.settings.bluetooth.ui.model.FragmentTypeModel
import com.android.settings.overlay.FeatureFactory.Companion.featureFactory
@@ -39,11 +36,8 @@ import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
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 application: Application,
@@ -141,43 +135,6 @@ class BluetoothDeviceDetailsViewModel(
}
}
suspend fun getLayout(fragment: FragmentTypeModel): DeviceSettingLayout? {
val configItems = getItems(fragment) ?: 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<DeviceSettingLayoutColumn>>()
for (i in settings.indices) {
val configItem = configItems[i]
val setting = settings[i]
val isXmlPreference = configItem is DeviceSettingConfigItemModel.BuiltinItem
if (!isXmlPreference && setting == null) {
continue
}
positionMapping[i] =
listOf(
DeviceSettingLayoutColumn(
configItem.settingId,
configItem.highlighted,
)
)
}
positionMapping
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), initialValue = mapOf())
return DeviceSettingLayout(
configItems.indices.map { idx ->
DeviceSettingLayoutRow(positionToSettingIds.map { it[idx] ?: emptyList() })
}
)
}
class Factory(
private val application: Application,
private val bluetoothAdapter: BluetoothAdapter,

View File

@@ -16,7 +16,7 @@
package com.android.settings.bluetooth.ui.view
import android.app.settings.SettingsEnums;
import android.app.settings.SettingsEnums
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.content.Intent
@@ -25,6 +25,7 @@ import androidx.fragment.app.FragmentActivity
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import androidx.test.core.app.ApplicationProvider
import com.android.settings.bluetooth.ui.model.DeviceSettingPreferenceModel
import com.android.settings.bluetooth.ui.model.FragmentTypeModel
@@ -33,6 +34,7 @@ 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.DeviceSettingActionModel
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.DeviceSettingIcon
@@ -58,14 +60,15 @@ import org.mockito.Mock
import org.mockito.Mockito.any
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.mockito.Spy
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.mockito.kotlin.doNothing
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.shadows.ShadowLooper
import org.robolectric.shadows.ShadowLooper.shadowMainLooper
@ExperimentalCoroutinesApi
@RunWith(RobolectricTestRunner::class)
class DeviceDetailsFragmentFormatterTest {
@@ -78,7 +81,7 @@ class DeviceDetailsFragmentFormatterTest {
@Mock private lateinit var headerController: AbstractPreferenceController
@Mock private lateinit var buttonController: AbstractPreferenceController
private lateinit var context: Context
@Spy private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var fragment: TestFragment
private lateinit var underTest: DeviceDetailsFragmentFormatter
private lateinit var featureFactory: FakeFeatureFactory
@@ -87,11 +90,15 @@ class DeviceDetailsFragmentFormatterTest {
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
featureFactory = FakeFeatureFactory.setupForTest()
doNothing().`when`(context).startActivity(any(Intent::class.java))
`when`(
featureFactory.bluetoothFeatureProvider.getDeviceSettingRepository(
eq(context), eq(bluetoothAdapter), any()))
any(),
eq(bluetoothAdapter),
any(),
)
)
.thenReturn(repository)
fragmentActivity = Robolectric.setupActivity(FragmentActivity::class.java)
assertThat(fragmentActivity.applicationContext).isNotNull()
@@ -115,7 +122,8 @@ class DeviceDetailsFragmentFormatterTest {
listOf(profileController, headerController, buttonController),
bluetoothAdapter,
cachedDevice,
testScope.testScheduler)
testScope.testScheduler,
)
}
@Test
@@ -124,11 +132,16 @@ class DeviceDetailsFragmentFormatterTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice))
.thenReturn(
DeviceSettingConfigModel(
listOf(), listOf(), DeviceSettingConfigItemModel.AppProvidedItem(12345, false)))
val intent = Intent().apply {
setAction(Intent.ACTION_VIEW)
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
listOf(),
listOf(),
DeviceSettingConfigItemModel.AppProvidedItem(12345, false),
)
)
val intent =
Intent().apply {
setAction(Intent.ACTION_VIEW)
setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
`when`(repository.getDeviceSetting(cachedDevice, 12345))
.thenReturn(
flowOf(
@@ -136,12 +149,15 @@ class DeviceDetailsFragmentFormatterTest {
cachedDevice = cachedDevice,
id = 12345,
intent = intent,
)))
)
)
)
var helpPreference: DeviceSettingPreferenceModel.HelpPreference? = null
underTest.getMenuItem(FragmentTypeModel.DeviceDetailsMoreSettingsFragment).onEach {
helpPreference = it
}.launchIn(testScope.backgroundScope)
underTest
.getMenuItem(FragmentTypeModel.DeviceDetailsMoreSettingsFragment)
.onEach { helpPreference = it }
.launchIn(testScope.backgroundScope)
delay(100)
runCurrent()
ShadowLooper.idleMainLooper()
@@ -171,13 +187,19 @@ class DeviceDetailsFragmentFormatterTest {
listOf(
DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_HEADER,
highlighted = false, preferenceKey = "bluetooth_device_header"),
highlighted = false,
preferenceKey = "bluetooth_device_header",
),
DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_BLUETOOTH_PROFILES,
highlighted = false, preferenceKey = "bluetooth_profiles"),
highlighted = false,
preferenceKey = "bluetooth_profiles",
),
),
listOf(),
null))
null,
)
)
underTest.updateLayout(FragmentTypeModel.DeviceDetailsMainFragment)
runCurrent()
@@ -189,13 +211,17 @@ class DeviceDetailsFragmentFormatterTest {
SettingsEnums.PAGE_UNKNOWN,
SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_ITEM_SHOWN,
0,
"bluetooth_device_header", 1)
"bluetooth_device_header",
1,
)
verify(featureFactory.metricsFeatureProvider)
.action(
SettingsEnums.PAGE_UNKNOWN,
SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_ITEM_SHOWN,
0,
"bluetooth_profiles", 1)
"bluetooth_profiles",
1,
)
}
}
@@ -209,16 +235,22 @@ class DeviceDetailsFragmentFormatterTest {
DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_HEADER,
highlighted = false,
preferenceKey = "bluetooth_device_header"),
preferenceKey = "bluetooth_device_header",
),
DeviceSettingConfigItemModel.AppProvidedItem(
DeviceSettingId.DEVICE_SETTING_ID_ANC, highlighted = false),
DeviceSettingId.DEVICE_SETTING_ID_ANC,
highlighted = false,
),
DeviceSettingConfigItemModel.BuiltinItem.CommonBuiltinItem(
DeviceSettingId.DEVICE_SETTING_ID_BLUETOOTH_PROFILES,
highlighted = false,
preferenceKey = "bluetooth_profiles"),
preferenceKey = "bluetooth_profiles",
),
),
listOf(),
null))
null,
)
)
`when`(repository.getDeviceSetting(cachedDevice, DeviceSettingId.DEVICE_SETTING_ID_ANC))
.thenReturn(
flowOf(
@@ -231,11 +263,17 @@ class DeviceDetailsFragmentFormatterTest {
ToggleModel(
"",
DeviceSettingIcon.BitmapIcon(
Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)))),
Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
),
)
),
isActive = true,
state = DeviceSettingStateModel.MultiTogglePreferenceState(0),
isAllowedChangingState = true,
updateState = {})))
updateState = {},
)
)
)
underTest.updateLayout(FragmentTypeModel.DeviceDetailsMainFragment)
runCurrent()
@@ -244,13 +282,119 @@ class DeviceDetailsFragmentFormatterTest {
.containsExactly(
"bluetooth_device_header",
"DEVICE_SETTING_${DeviceSettingId.DEVICE_SETTING_ID_ANC}",
"bluetooth_profiles")
"bluetooth_profiles",
)
verify(featureFactory.metricsFeatureProvider)
.action(
SettingsEnums.PAGE_UNKNOWN,
SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_ITEM_SHOWN,
0,
"DEVICE_SETTING_${DeviceSettingId.DEVICE_SETTING_ID_ANC}", 1
"DEVICE_SETTING_${DeviceSettingId.DEVICE_SETTING_ID_ANC}",
1,
)
}
}
@Test
fun updateLayout_plainPreferenceClicked() {
testScope.runTest {
val settingId = 12345
val intent = Intent("test_intent")
`when`(repository.getDeviceSettingsConfig(cachedDevice))
.thenReturn(
DeviceSettingConfigModel(
listOf(
DeviceSettingConfigItemModel.AppProvidedItem(
settingId,
highlighted = false,
)
),
listOf(),
null,
)
)
`when`(repository.getDeviceSetting(cachedDevice, settingId))
.thenReturn(
flowOf(
DeviceSettingModel.ActionSwitchPreference(
cachedDevice = cachedDevice,
id = settingId,
title = "title",
summary = "summary",
icon = null,
action = DeviceSettingActionModel.IntentAction(intent),
)
)
)
underTest.updateLayout(FragmentTypeModel.DeviceDetailsMainFragment)
runCurrent()
val displayedPrefs = getDisplayedPreferences()
displayedPrefs[0].performClick()
assertThat(displayedPrefs).hasSize(1)
verify(context).startActivity(intent)
verify(featureFactory.metricsFeatureProvider)
.action(
SettingsEnums.PAGE_UNKNOWN,
SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_ITEM_CLICKED,
0,
"DEVICE_SETTING_$settingId",
2,
)
}
}
@Test
fun updateLayout_switchPreferenceClicked() {
val settingId = 12345
testScope.runTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice))
.thenReturn(
DeviceSettingConfigModel(
listOf(
DeviceSettingConfigItemModel.AppProvidedItem(
settingId,
highlighted = false,
)
),
listOf(),
null,
)
)
`when`(repository.getDeviceSetting(cachedDevice, settingId))
.thenReturn(
flowOf(
DeviceSettingModel.ActionSwitchPreference(
cachedDevice = cachedDevice,
id = settingId,
title = "title",
summary = "summary",
icon = null,
action = null,
switchState = DeviceSettingStateModel.ActionSwitchPreferenceState(true),
isAllowedChangingState = true,
updateState = {},
)
)
)
underTest.updateLayout(FragmentTypeModel.DeviceDetailsMainFragment)
runCurrent()
val displayedPrefs = getDisplayedPreferences()
displayedPrefs[0].performClick()
assertThat(displayedPrefs).hasSize(1)
assertThat(displayedPrefs[0]).isInstanceOf(SwitchPreferenceCompat::class.java)
verify(featureFactory.metricsFeatureProvider)
.action(
SettingsEnums.PAGE_UNKNOWN,
SettingsEnums.ACTION_BLUETOOTH_DEVICE_DETAILS_ITEM_CLICKED,
0,
"DEVICE_SETTING_$settingId",
0,
)
}
}

View File

@@ -20,7 +20,6 @@ import android.app.Application
import android.bluetooth.BluetoothAdapter
import android.graphics.Bitmap
import androidx.test.core.app.ApplicationProvider
import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout
import com.android.settings.bluetooth.ui.model.DeviceSettingPreferenceModel
import com.android.settings.bluetooth.ui.model.FragmentTypeModel
import com.android.settings.testutils.FakeFeatureFactory
@@ -164,74 +163,6 @@ class BluetoothDeviceDetailsViewModelTest {
}
}
@Test
fun getLayout_builtinDeviceSettings() {
testScope.runTest {
`when`(repository.getDeviceSettingsConfig(cachedDevice))
.thenReturn(
DeviceSettingConfigModel(
listOf(BUILTIN_SETTING_ITEM_1, BUILDIN_SETTING_ITEM_2), listOf(), null))
val layout = underTest.getLayout(FragmentTypeModel.DeviceDetailsMainFragment)!!
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(),
null))
`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(FragmentTypeModel.DeviceDetailsMainFragment)!!
assertThat(getLatestLayout(layout))
.isEqualTo(
listOf(
listOf(DeviceSettingId.DEVICE_SETTING_ID_HEADER),
listOf(remoteSettingId1),
listOf(remoteSettingId2),
listOf(remoteSettingId3),
))
}
}
private fun getLatestLayout(layout: DeviceSettingLayout): List<List<Int>> {
val latestLayout = MutableList(layout.rows.size) { emptyList<Int>() }
for (i in layout.rows.indices) {
layout.rows[i]
.columns
.onEach { latestLayout[i] = it.map { c -> c.settingId } }
.launchIn(testScope.backgroundScope)
}
testScope.runCurrent()
return latestLayout.filter { !it.isEmpty() }.toList()
}
private fun buildMultiTogglePreference(settingId: Int) =
DeviceSettingModel.MultiTogglePreference(
cachedDevice,