diff --git a/res/drawable/device_details_spotlight_preference_background.xml b/res/drawable/device_details_spotlight_preference_background.xml new file mode 100644 index 00000000000..58a29a10dcd --- /dev/null +++ b/res/drawable/device_details_spotlight_preference_background.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/res/layout/bluetooth_device_spotlight_preference.xml b/res/layout/bluetooth_device_spotlight_preference.xml new file mode 100644 index 00000000000..ee2778a53ec --- /dev/null +++ b/res/layout/bluetooth_device_spotlight_preference.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/com/android/settings/bluetooth/ui/layout/DeviceSettingLayout.kt b/src/com/android/settings/bluetooth/ui/layout/DeviceSettingLayout.kt deleted file mode 100644 index 5987e5a2079..00000000000 --- a/src/com/android/settings/bluetooth/ui/layout/DeviceSettingLayout.kt +++ /dev/null @@ -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) - -/** Represent a row in the layout. */ -data class DeviceSettingLayoutRow(val columns: Flow>) - -/** Represent a column in a row. */ -data class DeviceSettingLayoutColumn(val settingId: Int, val highlighted: Boolean) diff --git a/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt b/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt index b7ec32eb8e6..0658b1da40d 100644 --- a/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt +++ b/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatter.kt @@ -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, 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 = 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( + 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> = - 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, 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 diff --git a/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt b/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt index 8d3b8539b98..5434ec98c55 100644 --- a/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt +++ b/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModel.kt @@ -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() - .associateBy({ it.settingId }, { getDeviceSetting(cachedDevice, it.settingId) }) - - val configDeviceSetting = - configItems.map { idToDeviceSetting[it.settingId] ?: flowOf(null) } - val positionToSettingIds = - combine(configDeviceSetting) { settings -> - val positionMapping = mutableMapOf>() - 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, 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 index d0bd27d7a6e..1eb15e50f62 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt +++ b/tests/robotests/src/com/android/settings/bluetooth/ui/view/DeviceDetailsFragmentFormatterTest.kt @@ -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, ) } } diff --git a/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt b/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt index caeea942f62..bf66a04cede 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt +++ b/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt @@ -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> { - val latestLayout = MutableList(layout.rows.size) { emptyList() } - 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,