Merge "Rearrange bluetooth device details fragment according to config" into main
diff --git a/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java b/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java
index 0690186..442acd2 100644
--- a/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java
+++ b/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java
@@ -101,7 +101,8 @@
         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 ccf38ed..bd762a1 100644
--- a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
+++ b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
@@ -43,10 +43,12 @@
 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.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 @@
     @VisibleForTesting
     CachedBluetoothDevice mCachedDevice;
     BluetoothAdapter mBluetoothAdapter;
+    @VisibleForTesting
+    DeviceDetailsFragmentFormatter mFormatter;
 
     @Nullable
     InputDevice mInputDevice;
@@ -214,18 +220,29 @@
             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 @@
             }
         }
         mExtraControlUriLoaded |= controlUri != null;
-        final SlicePreferenceController slicePreferenceController = use(
-                SlicePreferenceController.class);
-        slicePreferenceController.setSliceUri(sliceEnabled ? controlUri : null);
-        slicePreferenceController.onStart();
-        slicePreferenceController.displayPreference(getPreferenceScreen());
+
+        Uri finalControlUri = controlUri;
+        getController(SlicePreferenceController.class, controller -> {
+            controller.setSliceUri(sliceEnabled ? finalControlUri : null);
+            controller.onStart();
+            controller.displayPreference(getPreferenceScreen());
+        });
+
 
         // Temporarily fix the issue that the page will be automatically scrolled to a wrong
         // position when entering the page. This will make sure the bluetooth header is shown on top
         // of the page.
-        use(LeAudioBluetoothDetailsHeaderController.class).displayPreference(
-                getPreferenceScreen());
-        use(AdvancedBluetoothDetailsHeaderController.class).displayPreference(
-                getPreferenceScreen());
-        use(BluetoothDetailsHeaderController.class).displayPreference(
-                getPreferenceScreen());
+        getController(
+                LeAudioBluetoothDetailsHeaderController.class,
+                controller -> controller.displayPreference(getPreferenceScreen()));
+        getController(
+                AdvancedBluetoothDetailsHeaderController.class,
+                controller -> controller.displayPreference(getPreferenceScreen()));
+        getController(
+                BluetoothDetailsHeaderController.class,
+                controller -> controller.displayPreference(getPreferenceScreen()));
+    }
+
+    protected <T extends AbstractPreferenceController> void getController(Class<T> clazz,
+            Consumer<T> action) {
+        T controller = use(clazz);
+        if (controller != null) {
+            action.accept(controller);
+        }
     }
 
     private final ViewTreeObserver.OnGlobalLayoutListener mOnGlobalLayoutListener =
@@ -309,6 +340,14 @@
     }
 
     @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();
         finishFragmentIfNecessary();
@@ -359,7 +398,29 @@
     }
 
     @Override
+    protected void addPreferenceController(AbstractPreferenceController controller) {
+        if (Flags.enableBluetoothDeviceDetailsPolish()) {
+            List<String> keys = mFormatter.getVisiblePreferenceKeysForMainPage();
+            Lifecycle lifecycle = getSettingsLifecycle();
+            if (keys == null || keys.contains(controller.getPreferenceKey())) {
+                super.addPreferenceController(controller);
+            } else if (controller instanceof LifecycleObserver) {
+                lifecycle.removeObserver((LifecycleObserver) controller);
+            }
+        } else {
+            super.addPreferenceController(controller);
+        }
+    }
+
+    @Override
     protected List<AbstractPreferenceController> createPreferenceControllers(Context context) {
+        if (Flags.enableBluetoothDeviceDetailsPolish()) {
+            mFormatter =
+                    FeatureFactory.getFeatureFactory()
+                            .getBluetoothFeatureProvider()
+                            .getDeviceDetailsFragmentFormatter(
+                                    requireContext(), this, mBluetoothAdapter, mCachedDevice);
+        }
         ArrayList<AbstractPreferenceController> controllers = new ArrayList<>();
 
         if (mCachedDevice != null) {
diff --git a/src/com/android/settings/bluetooth/BluetoothFeatureProvider.java b/src/com/android/settings/bluetooth/BluetoothFeatureProvider.java
index 1751082..5941344 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 @@
      */
     Set<String> getInvisibleProfilePreferenceKeys(
             Context context, BluetoothDevice bluetoothDevice);
+
+    /** Gets DeviceSettingRepository. */
+    @NonNull
+    DeviceSettingRepository getDeviceSettingRepository(
+            @NonNull Context context,
+            @NonNull BluetoothAdapter bluetoothAdapter,
+            @NonNull LifecycleCoroutineScope scope);
+
+    /** Gets device details fragment layout formatter. */
+    @NonNull
+    DeviceDetailsFragmentFormatter getDeviceDetailsFragmentFormatter(
+            @NonNull Context context,
+            @NonNull SettingsPreferenceFragment fragment,
+            @NonNull BluetoothAdapter bluetoothAdapter,
+            @NonNull CachedBluetoothDevice cachedDevice);
 }
diff --git a/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.java b/src/com/android/settings/bluetooth/BluetoothFeatureProviderImpl.java
index 2d4ac49..ae6e740 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.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 @@
             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 e4ca00d..b42e7d0 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 0000000..87e2e8b
--- /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<DeviceSettingLayoutRow>)
+
+/** Represent a row in the layout. */
+data class DeviceSettingLayoutRow(val settingIds: Flow<List<Int>>)
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 0000000..3b77aae
--- /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<String>?
+
+    /** Updates device details fragment layout. */
+    fun updateLayout()
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DeviceDetailsFragmentFormatterImpl(
+    private val context: Context,
+    private val fragment: SettingsPreferenceFragment,
+    bluetoothAdapter: BluetoothAdapter,
+    private val cachedDevice: CachedBluetoothDevice
+) : DeviceDetailsFragmentFormatter {
+    private val repository =
+        featureFactory.bluetoothFeatureProvider.getDeviceSettingRepository(
+            context, bluetoothAdapter, fragment.lifecycleScope)
+    private val viewModel: BluetoothDeviceDetailsViewModel =
+        ViewModelProvider(
+                fragment,
+                BluetoothDeviceDetailsViewModel.Factory(
+                    repository,
+                    cachedDevice,
+                ))
+            .get(BluetoothDeviceDetailsViewModel::class.java)
+
+    override fun getVisiblePreferenceKeysForMainPage(): List<String>? = runBlocking {
+        viewModel.getItems()?.filterIsInstance<DeviceSettingConfigItemModel.BuiltinItem>()?.map {
+            it.preferenceKey
+        }
+    }
+
+    /** Updates bluetooth device details fragment layout. */
+    override fun updateLayout() = runBlocking {
+        val items = viewModel.getItems() ?: return@runBlocking
+        val layout = viewModel.getLayout() ?: return@runBlocking
+        val prefKeyToSettingId =
+            items
+                .filterIsInstance<DeviceSettingConfigItemModel.BuiltinItem>()
+                .associateBy({ it.preferenceKey }, { it.settingId })
+
+        val settingIdToXmlPreferences: MutableMap<Int, Preference> = HashMap()
+        for (i in 0 until fragment.preferenceScreen.preferenceCount) {
+            val pref = fragment.preferenceScreen.getPreference(i)
+            prefKeyToSettingId[pref.key]?.let { id -> settingIdToXmlPreferences[id] = pref }
+        }
+        fragment.preferenceScreen.removeAll()
+
+        for (row in items.indices) {
+            val settingId = items[row].settingId
+            if (settingIdToXmlPreferences.containsKey(settingId)) {
+                fragment.preferenceScreen.addPreference(
+                    settingIdToXmlPreferences[settingId]!!.apply { order = row })
+            } else {
+                val pref =
+                    ComposePreference(context)
+                        .apply {
+                            key = getPreferenceKey(settingId)
+                            order = row
+                        }
+                        .also { pref -> pref.setContent { buildPreference(layout, row) } }
+                fragment.preferenceScreen.addPreference(pref)
+            }
+        }
+    }
+
+    @Composable
+    private fun buildPreference(layout: DeviceSettingLayout, row: Int) {
+        val contents by
+            remember(row) {
+                    layout.rows[row].settingIds.flatMapLatest { settingIds ->
+                        if (settingIds.isEmpty()) {
+                            flowOf(emptyList<DeviceSettingModel>())
+                        } else {
+                            combine(
+                                settingIds.map { settingId ->
+                                    viewModel.getDeviceSetting(cachedDevice, settingId)
+                                }) {
+                                    it.toList()
+                                }
+                        }
+                    }
+                }
+                .collectAsStateWithLifecycle(initialValue = listOf())
+
+        val settings = contents
+        when (settings.size) {
+            0 -> {}
+            1 -> {
+                when (val setting = settings[0]) {
+                    is DeviceSettingModel.ActionSwitchPreference -> {
+                        buildActionSwitchPreference(setting)
+                    }
+                    is DeviceSettingModel.MultiTogglePreference -> {
+                        buildMultiTogglePreference(listOf(setting))
+                    }
+                    null -> {}
+                    else -> {
+                        Log.w(TAG, "Unknown preference type ${setting.id}, skip.")
+                    }
+                }
+            }
+            else -> {
+                if (!settings.all { it is DeviceSettingModel.MultiTogglePreference }) {
+                    return
+                }
+                buildMultiTogglePreference(
+                    settings.filterIsInstance<DeviceSettingModel.MultiTogglePreference>())
+            }
+        }
+    }
+
+    @Composable
+    private fun buildMultiTogglePreference(prefs: List<DeviceSettingModel.MultiTogglePreference>) {
+        MultiTogglePreferenceGroup(prefs)
+    }
+
+    @Composable
+    private fun buildActionSwitchPreference(model: DeviceSettingModel.ActionSwitchPreference) {
+        if (model.switchState != null) {
+            val switchPrefModel =
+                object : SwitchPreferenceModel {
+                    override val title = model.title
+                    override val summary = { model.summary ?: "" }
+                    override val checked = { model.switchState?.checked }
+                    override val onCheckedChange = { newChecked: Boolean ->
+                        model.updateState?.invoke(
+                            DeviceSettingStateModel.ActionSwitchPreferenceState(newChecked))
+                        Unit
+                    }
+                    override val icon = @Composable { deviceSettingIcon(model) }
+                }
+            if (model.intent != null) {
+                TwoTargetSwitchPreference(switchPrefModel) { context.startActivity(model.intent) }
+            } else {
+                SwitchPreference(switchPrefModel)
+            }
+        } else {
+            SpaPreference(
+                object : PreferenceModel {
+                    override val title = model.title
+                    override val summary = { model.summary ?: "" }
+                    override val onClick = {
+                        model.intent?.let { context.startActivity(it) }
+                        Unit
+                    }
+                    override val icon = @Composable { deviceSettingIcon(model) }
+                })
+        }
+    }
+
+    @Composable
+    private fun deviceSettingIcon(model: DeviceSettingModel.ActionSwitchPreference) {
+        model.icon?.let { bitmap ->
+            Icon(
+                bitmap.asImageBitmap(),
+                contentDescription = null,
+                modifier = Modifier.size(SettingsDimension.itemIconSize),
+                tint = LocalContentColor.current)
+        }
+    }
+
+    private fun getPreferenceKey(settingId: Int) = "DEVICE_SETTING_${settingId}"
+
+    companion object {
+        const val TAG = "DeviceDetailsFormatter"
+    }
+}
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 0000000..1c48614
--- /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<DeviceSettingConfigItemModel>? = items.await()?.mainItems
+
+    fun getDeviceSetting(cachedDevice: CachedBluetoothDevice, @DeviceSettingId settingId: Int) =
+        deviceSettingRepository.getDeviceSetting(cachedDevice, settingId)
+
+    suspend fun getLayout(): DeviceSettingLayout? {
+        val configItems = getItems() ?: return null
+        val idToDeviceSetting =
+            configItems
+                .filterIsInstance<DeviceSettingConfigItemModel.AppProvidedItem>()
+                .associateBy({ it.settingId }, { getDeviceSetting(cachedDevice, it.settingId) })
+
+        val configDeviceSetting =
+            configItems.map { idToDeviceSetting[it.settingId] ?: flowOf(null) }
+        val positionToSettingIds =
+            combine(configDeviceSetting) { settings ->
+                    val positionMapping = mutableMapOf<Int, List<Int>>()
+                    var multiToggleSettingIds: MutableList<Int>? = null
+                    for (i in settings.indices) {
+                        val configItem = configItems[i]
+                        val setting = settings[i]
+                        val isXmlPreference = configItem is DeviceSettingConfigItemModel.BuiltinItem
+                        if (!isXmlPreference && setting == null) {
+                            continue
+                        }
+                        if (setting !is DeviceSettingModel.MultiTogglePreference) {
+                            multiToggleSettingIds = null
+                            positionMapping[i] = listOf(configItem.settingId)
+                            continue
+                        }
+
+                        if (multiToggleSettingIds != null) {
+                            multiToggleSettingIds.add(setting.id)
+                        } else {
+                            multiToggleSettingIds = mutableListOf(setting.id)
+                            positionMapping[i] = multiToggleSettingIds
+                        }
+                    }
+                    positionMapping
+                }
+                .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), initialValue = mapOf())
+        return DeviceSettingLayout(
+            configItems.indices.map { idx ->
+                DeviceSettingLayoutRow(positionToSettingIds.map { it[idx] ?: emptyList() })
+            })
+    }
+
+    class Factory(
+        private val deviceSettingRepository: DeviceSettingRepository,
+        private val cachedDevice: CachedBluetoothDevice,
+    ) : ViewModelProvider.Factory {
+        override fun <T : ViewModel> create(modelClass: Class<T>): T {
+            @Suppress("UNCHECKED_CAST")
+            return BluetoothDeviceDetailsViewModel(deviceSettingRepository, cachedDevice) as T
+        }
+    }
+
+    companion object {
+        private const val TAG = "BluetoothDeviceDetailsViewModel"
+    }
+}
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 0000000..1bb8f20
--- /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 5e8fb26..2e835a0 100644
--- a/src/com/android/settings/slices/SlicePreferenceController.java
+++ b/src/com/android/settings/slices/SlicePreferenceController.java
@@ -20,6 +20,7 @@
 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 @@
         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 50aa771..19d0edd 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.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 @@
     private InputManager mInputManager;
     @Mock
     private CompanionDeviceManager mCompanionDeviceManager;
+    @Mock
+    private DeviceDetailsFragmentFormatter mFormatter;
 
     @Before
     public void setUp() {
@@ -111,7 +114,10 @@
                 .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 0000000..468a2f0
--- /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<Context>()
+        featureFactory = FakeFeatureFactory.setupForTest()
+        `when`(
+                featureFactory.bluetoothFeatureProvider.getDeviceSettingRepository(
+                    eq(context), eq(bluetoothAdapter), any()))
+            .thenReturn(repository)
+        val fragmentActivity = Robolectric.setupActivity(FragmentActivity::class.java)
+        assertThat(fragmentActivity.applicationContext).isNotNull()
+        fragment = TestFragment(context)
+        fragmentActivity.supportFragmentManager.beginTransaction().add(fragment, null).commit()
+        shadowMainLooper().idle()
+
+        fragment.preferenceScreen.run {
+            addPreference(Preference(context).apply { key = "bluetooth_device_header" })
+            addPreference(Preference(context).apply { key = "action_buttons" })
+            addPreference(Preference(context).apply { key = "keyboard_settings" })
+        }
+
+        underTest =
+            DeviceDetailsFragmentFormatterImpl(context, fragment, bluetoothAdapter, cachedDevice)
+    }
+
+    @Test
+    fun getVisiblePreferenceKeysForMainPage_hasConfig_returnList() {
+        testScope.runTest {
+            `when`(repository.getDeviceSettingsConfig(cachedDevice))
+                .thenReturn(
+                    DeviceSettingConfigModel(
+                        listOf(
+                            DeviceSettingConfigItemModel.BuiltinItem(
+                                DeviceSettingId.DEVICE_SETTING_ID_HEADER,
+                                "bluetooth_device_header"),
+                            DeviceSettingConfigItemModel.BuiltinItem(
+                                DeviceSettingId.DEVICE_SETTING_ID_ACTION_BUTTONS, "action_buttons"),
+                        ),
+                        listOf(),
+                        "footer"))
+
+            val keys = underTest.getVisiblePreferenceKeysForMainPage()
+
+            assertThat(keys).containsExactly("bluetooth_device_header", "action_buttons")
+        }
+    }
+
+    @Test
+    fun getVisiblePreferenceKeysForMainPage_noConfig_returnNull() {
+        testScope.runTest {
+            `when`(repository.getDeviceSettingsConfig(cachedDevice)).thenReturn(null)
+
+            val keys = underTest.getVisiblePreferenceKeysForMainPage()
+
+            assertThat(keys).isNull()
+        }
+    }
+
+    @Test
+    fun updateLayout_configIsNull_notChange() {
+        testScope.runTest {
+            `when`(repository.getDeviceSettingsConfig(cachedDevice)).thenReturn(null)
+
+            underTest.updateLayout()
+
+            assertThat(getDisplayedPreferences().map { it.key })
+                .containsExactly("bluetooth_device_header", "action_buttons", "keyboard_settings")
+        }
+    }
+
+    @Test
+    fun updateLayout_itemsNotInConfig_hide() {
+        testScope.runTest {
+            `when`(repository.getDeviceSettingsConfig(cachedDevice))
+                .thenReturn(
+                    DeviceSettingConfigModel(
+                        listOf(
+                            DeviceSettingConfigItemModel.BuiltinItem(
+                                DeviceSettingId.DEVICE_SETTING_ID_HEADER,
+                                "bluetooth_device_header"),
+                            DeviceSettingConfigItemModel.BuiltinItem(
+                                DeviceSettingId.DEVICE_SETTING_ID_KEYBOARD_SETTINGS,
+                                "keyboard_settings"),
+                        ),
+                        listOf(),
+                        "footer"))
+
+            underTest.updateLayout()
+
+            assertThat(getDisplayedPreferences().map { it.key })
+                .containsExactly("bluetooth_device_header", "keyboard_settings")
+        }
+    }
+
+    @Test
+    fun updateLayout_newItems_displayNewItems() {
+        testScope.runTest {
+            `when`(repository.getDeviceSettingsConfig(cachedDevice))
+                .thenReturn(
+                    DeviceSettingConfigModel(
+                        listOf(
+                            DeviceSettingConfigItemModel.BuiltinItem(
+                                DeviceSettingId.DEVICE_SETTING_ID_HEADER,
+                                "bluetooth_device_header"),
+                            DeviceSettingConfigItemModel.AppProvidedItem(
+                                DeviceSettingId.DEVICE_SETTING_ID_ANC),
+                            DeviceSettingConfigItemModel.BuiltinItem(
+                                DeviceSettingId.DEVICE_SETTING_ID_KEYBOARD_SETTINGS,
+                                "keyboard_settings"),
+                        ),
+                        listOf(),
+                        "footer"))
+            `when`(repository.getDeviceSetting(cachedDevice, DeviceSettingId.DEVICE_SETTING_ID_ANC))
+                .thenReturn(
+                    flowOf(
+                        DeviceSettingModel.MultiTogglePreference(
+                            cachedDevice,
+                            DeviceSettingId.DEVICE_SETTING_ID_ANC,
+                            "title",
+                            toggles =
+                                listOf(
+                                    ToggleModel(
+                                        "", Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))),
+                            isActive = true,
+                            state = DeviceSettingStateModel.MultiTogglePreferenceState(0),
+                            isAllowedChangingState = true,
+                            updateState = {})))
+
+            underTest.updateLayout()
+
+            assertThat(getDisplayedPreferences().map { it.key })
+                .containsExactly(
+                    "bluetooth_device_header",
+                    "DEVICE_SETTING_${DeviceSettingId.DEVICE_SETTING_ID_ANC}",
+                    "keyboard_settings")
+        }
+    }
+
+    private fun getDisplayedPreferences(): List<Preference> {
+        val prefs = mutableListOf<Preference>()
+        for (i in 0..<fragment.preferenceScreen.preferenceCount) {
+            prefs.add(fragment.preferenceScreen.getPreference(i))
+        }
+        return prefs
+    }
+
+    class TestFragment(context: Context) : DashboardFragment() {
+        private val mPreferenceManager: PreferenceManager = PreferenceManager(context)
+
+        init {
+            mPreferenceManager.setPreferences(mPreferenceManager.createPreferenceScreen(context))
+        }
+
+        public override fun getPreferenceScreenResId(): Int = 0
+
+        override fun getLogTag(): String = "TestLogTag"
+
+        override fun getPreferenceScreen(): PreferenceScreen {
+            return mPreferenceManager.preferenceScreen
+        }
+
+        override fun getMetricsCategory(): Int = 0
+
+        override fun getPreferenceManager(): PreferenceManager {
+            return mPreferenceManager
+        }
+    }
+
+    private companion object {}
+}
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
new file mode 100644
index 0000000..cc462bb
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/bluetooth/ui/viewmodel/BluetoothDeviceDetailsViewModelTest.kt
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.bluetooth.ui.viewmodel
+
+import android.bluetooth.BluetoothAdapter
+import android.content.Context
+import android.graphics.Bitmap
+import androidx.test.core.app.ApplicationProvider
+import com.android.settings.bluetooth.ui.layout.DeviceSettingLayout
+import com.android.settings.testutils.FakeFeatureFactory
+import com.android.settingslib.bluetooth.CachedBluetoothDevice
+import com.android.settingslib.bluetooth.devicesettings.DeviceSettingId
+import com.android.settingslib.bluetooth.devicesettings.data.repository.DeviceSettingRepository
+import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigItemModel
+import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingConfigModel
+import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingModel
+import com.android.settingslib.bluetooth.devicesettings.shared.model.DeviceSettingStateModel
+import com.android.settingslib.bluetooth.devicesettings.shared.model.ToggleModel
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class BluetoothDeviceDetailsViewModelTest {
+    @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+    @Mock private lateinit var cachedDevice: CachedBluetoothDevice
+
+    @Mock private lateinit var bluetoothAdapter: BluetoothAdapter
+
+    @Mock private lateinit var repository: DeviceSettingRepository
+
+    private lateinit var underTest: BluetoothDeviceDetailsViewModel
+    private lateinit var featureFactory: FakeFeatureFactory
+    private val testScope = TestScope()
+
+    @Before
+    fun setUp() {
+        val context = ApplicationProvider.getApplicationContext<Context>()
+        featureFactory = FakeFeatureFactory.setupForTest()
+        `when`(
+                featureFactory.bluetoothFeatureProvider.getDeviceSettingRepository(
+                    eq(context), eq(bluetoothAdapter), any()))
+            .thenReturn(repository)
+
+        underTest = BluetoothDeviceDetailsViewModel(repository, cachedDevice)
+    }
+
+    @Test
+    fun getItems_returnConfigMainItems() {
+        testScope.runTest {
+            `when`(repository.getDeviceSettingsConfig(cachedDevice))
+                .thenReturn(
+                    DeviceSettingConfigModel(
+                        listOf(BUILTIN_SETTING_ITEM_1, BUILDIN_SETTING_ITEM_2), listOf(), "footer"))
+
+            val keys = underTest.getItems()
+
+            assertThat(keys).containsExactly(BUILTIN_SETTING_ITEM_1, BUILDIN_SETTING_ITEM_2)
+        }
+    }
+
+    @Test
+    fun getLayout_builtinDeviceSettings() {
+        testScope.runTest {
+            `when`(repository.getDeviceSettingsConfig(cachedDevice))
+                .thenReturn(
+                    DeviceSettingConfigModel(
+                        listOf(BUILTIN_SETTING_ITEM_1, BUILDIN_SETTING_ITEM_2), listOf(), "footer"))
+
+            val layout = underTest.getLayout()!!
+
+            assertThat(getLatestLayout(layout))
+                .isEqualTo(
+                    listOf(
+                        listOf(DeviceSettingId.DEVICE_SETTING_ID_HEADER),
+                        listOf(DeviceSettingId.DEVICE_SETTING_ID_ACTION_BUTTONS)))
+        }
+    }
+
+    @Test
+    fun getLayout_remoteDeviceSettings() {
+        val remoteSettingId1 = 10001
+        val remoteSettingId2 = 10002
+        val remoteSettingId3 = 10003
+        testScope.runTest {
+            `when`(repository.getDeviceSettingsConfig(cachedDevice))
+                .thenReturn(
+                    DeviceSettingConfigModel(
+                        listOf(
+                            BUILTIN_SETTING_ITEM_1,
+                            buildRemoteSettingItem(remoteSettingId1),
+                            buildRemoteSettingItem(remoteSettingId2),
+                            buildRemoteSettingItem(remoteSettingId3),
+                        ),
+                        listOf(),
+                        "footer"))
+            `when`(repository.getDeviceSetting(cachedDevice, remoteSettingId1))
+                .thenReturn(flowOf(buildMultiTogglePreference(remoteSettingId1)))
+            `when`(repository.getDeviceSetting(cachedDevice, remoteSettingId2))
+                .thenReturn(flowOf(buildMultiTogglePreference(remoteSettingId2)))
+            `when`(repository.getDeviceSetting(cachedDevice, remoteSettingId3))
+                .thenReturn(flowOf(buildActionSwitchPreference(remoteSettingId3)))
+
+            val layout = underTest.getLayout()!!
+
+            assertThat(getLatestLayout(layout))
+                .isEqualTo(
+                    listOf(
+                        listOf(DeviceSettingId.DEVICE_SETTING_ID_HEADER),
+                        listOf(remoteSettingId1, remoteSettingId2),
+                        listOf(remoteSettingId3),
+                    ))
+        }
+    }
+
+    private fun getLatestLayout(layout: DeviceSettingLayout): List<List<Int>> {
+        var latestLayout = MutableList(layout.rows.size) { emptyList<Int>() }
+        for (i in layout.rows.indices) {
+            layout.rows[i]
+                .settingIds
+                .onEach { latestLayout[i] = it }
+                .launchIn(testScope.backgroundScope)
+        }
+
+        testScope.runCurrent()
+        return latestLayout.filter { !it.isEmpty() }.toList()
+    }
+
+    private fun buildMultiTogglePreference(settingId: Int) =
+        DeviceSettingModel.MultiTogglePreference(
+            cachedDevice,
+            settingId,
+            "title",
+            toggles = listOf(ToggleModel("", Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888))),
+            isActive = true,
+            state = DeviceSettingStateModel.MultiTogglePreferenceState(0),
+            isAllowedChangingState = true,
+            updateState = {})
+
+    private fun buildActionSwitchPreference(settingId: Int) =
+        DeviceSettingModel.ActionSwitchPreference(cachedDevice, settingId, "title")
+
+    private fun buildRemoteSettingItem(settingId: Int) =
+        DeviceSettingConfigItemModel.AppProvidedItem(settingId)
+
+    private companion object {
+        val BUILTIN_SETTING_ITEM_1 =
+            DeviceSettingConfigItemModel.BuiltinItem(
+                DeviceSettingId.DEVICE_SETTING_ID_HEADER, "bluetooth_device_header")
+        val BUILDIN_SETTING_ITEM_2 =
+            DeviceSettingConfigItemModel.BuiltinItem(
+                DeviceSettingId.DEVICE_SETTING_ID_ACTION_BUTTONS, "action_buttons")
+    }
+}