diff --git a/res/drawable/bluetooth_details_banner_background.xml b/res/drawable/bluetooth_details_banner_background.xml
new file mode 100644
index 00000000000..4a4e8f79ae7
--- /dev/null
+++ b/res/drawable/bluetooth_details_banner_background.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/bluetooth_details_banner.xml b/res/layout/bluetooth_details_banner.xml
new file mode 100644
index 00000000000..4572dd311a8
--- /dev/null
+++ b/res/layout/bluetooth_details_banner.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/xml/bluetooth_device_details_fragment.xml b/res/xml/bluetooth_device_details_fragment.xml
index 0c8662664f4..66e27f7f166 100644
--- a/res/xml/bluetooth_device_details_fragment.xml
+++ b/res/xml/bluetooth_device_details_fragment.xml
@@ -19,6 +19,13 @@
xmlns:settings="http://schemas.android.com/apk/res-auto"
android:title="@string/device_details_title">
+
+
(R.id.bluetooth_details_banner_message).text =
+ context.getString(R.string.device_details_key_missing_title, cachedDevice.name)
+ }
+
+ override fun isAvailable(): Boolean =
+ BluetoothUtils.getKeyMissingCount(cachedDevice.device)?.let { it > 0 } ?: false
+
+ private companion object {
+ const val KEY_BLUETOOTH_DETAILS_BANNER: String = "bluetooth_details_banner"
+ }
+}
diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsConfigurableFragment.kt b/src/com/android/settings/bluetooth/BluetoothDetailsConfigurableFragment.kt
new file mode 100644
index 00000000000..c3b7fd2d501
--- /dev/null
+++ b/src/com/android/settings/bluetooth/BluetoothDetailsConfigurableFragment.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.
+ */
+
+package com.android.settings.bluetooth
+
+import android.os.Bundle
+import android.os.UserManager
+import android.view.View
+import androidx.preference.Preference
+import androidx.preference.PreferenceCategory
+import androidx.preference.PreferenceGroup
+import com.android.settings.dashboard.RestrictedDashboardFragment
+
+/** Base class for bluetooth settings which makes the preference visibility/order configurable. */
+abstract class BluetoothDetailsConfigurableFragment :
+ RestrictedDashboardFragment(UserManager.DISALLOW_CONFIG_BLUETOOTH) {
+ private var displayOrder: List? = null
+
+ fun setPreferenceDisplayOrder(prefKeyOrder: List?) {
+ if (displayOrder == prefKeyOrder) {
+ return
+ }
+ displayOrder = prefKeyOrder
+ updatePreferenceOrder()
+ }
+
+ private val invisiblePrefCategory: PreferenceGroup by lazy {
+ preferenceScreen.findPreference(INVISIBLE_CATEGORY)
+ ?: run {
+ PreferenceCategory(requireContext())
+ .apply {
+ key = INVISIBLE_CATEGORY
+ isVisible = false
+ isOrderingAsAdded = true
+ }
+ .also { preferenceScreen.addPreference(it) }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ updatePreferenceOrder()
+ }
+
+ private fun updatePreferenceOrder() {
+ val order = displayOrder?: return
+ if (preferenceScreen == null) {
+ return
+ }
+ preferenceScreen.isOrderingAsAdded = true
+ val allPrefs =
+ (invisiblePrefCategory.getAndRemoveAll() + preferenceScreen.getAndRemoveAll()).filter {
+ it != invisiblePrefCategory
+ }
+ allPrefs.forEach { it.order = Preference.DEFAULT_ORDER }
+ val visiblePrefs =
+ allPrefs.filter { order.contains(it.key) }.sortedBy { order.indexOf(it.key) }
+ val invisiblePrefs = allPrefs.filter { !order.contains(it.key) }
+ preferenceScreen.addPreferences(visiblePrefs)
+ preferenceScreen.addPreference(invisiblePrefCategory)
+ invisiblePrefCategory.addPreferences(invisiblePrefs)
+ }
+
+ private fun PreferenceGroup.getAndRemoveAll(): List {
+ val prefs = mutableListOf()
+ for (i in 0..) {
+ for (pref in prefs) {
+ addPreference(pref)
+ }
+ }
+
+ private companion object {
+ const val INVISIBLE_CATEGORY = "invisible_profile_category"
+ }
+}
diff --git a/src/com/android/settings/bluetooth/BluetoothDetailsHeaderController.java b/src/com/android/settings/bluetooth/BluetoothDetailsHeaderController.java
index 3fbd445c8fc..0727025e669 100644
--- a/src/com/android/settings/bluetooth/BluetoothDetailsHeaderController.java
+++ b/src/com/android/settings/bluetooth/BluetoothDetailsHeaderController.java
@@ -62,7 +62,6 @@ public class BluetoothDetailsHeaderController extends BluetoothDetailsController
final LayoutPreference headerPreference = screen.findPreference(KEY_DEVICE_HEADER);
mHeaderController = EntityHeaderController.newInstance(mFragment.getActivity(), mFragment,
headerPreference.findViewById(R.id.entity_header));
- screen.addPreference(headerPreference);
}
protected void setHeaderProperties() {
diff --git a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
index 352242a8943..66c39d63108 100644
--- a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
+++ b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
@@ -17,7 +17,6 @@
package com.android.settings.bluetooth;
import static android.bluetooth.BluetoothDevice.BOND_NONE;
-import static android.os.UserManager.DISALLOW_CONFIG_BLUETOOTH;
import android.app.Activity;
import android.app.settings.SettingsEnums;
@@ -49,7 +48,6 @@ import com.android.settings.R;
import com.android.settings.bluetooth.ui.model.FragmentTypeModel;
import com.android.settings.bluetooth.ui.view.DeviceDetailsFragmentFormatter;
import com.android.settings.connecteddevice.stylus.StylusDevicesController;
-import com.android.settings.dashboard.RestrictedDashboardFragment;
import com.android.settings.flags.Flags;
import com.android.settings.inputmethod.KeyboardSettingsPreferenceController;
import com.android.settings.overlay.FeatureFactory;
@@ -66,7 +64,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
-public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment {
+public class BluetoothDeviceDetailsFragment extends BluetoothDetailsConfigurableFragment {
public static final String KEY_DEVICE_ADDRESS = "device_address";
private static final String TAG = "BTDeviceDetailsFrg";
private static final int METADATA_FAST_PAIR_CUSTOMIZED_FIELDS = 25;
@@ -102,6 +100,7 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
BluetoothAdapter mBluetoothAdapter;
@VisibleForTesting
DeviceDetailsFragmentFormatter mFormatter;
+ boolean mIsKeyMissingDevice = false;
@Nullable
InputDevice mInputDevice;
@@ -144,7 +143,7 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
};
public BluetoothDeviceDetailsFragment() {
- super(DISALLOW_CONFIG_BLUETOOTH);
+ super();
}
@VisibleForTesting
@@ -212,6 +211,9 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
finish();
return;
}
+ Integer keyMissingCount = BluetoothUtils.getKeyMissingCount(mCachedDevice.getDevice());
+ mIsKeyMissingDevice = keyMissingCount != null && keyMissingCount > 0;
+ setPreferenceDisplayOrder(generateDisplayedPreferenceKeys(mIsKeyMissingDevice));
getController(
AdvancedBluetoothDetailsHeaderController.class,
controller -> controller.init(mCachedDevice, this));
@@ -342,7 +344,7 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
- if (Flags.enableBluetoothDeviceDetailsPolish()) {
+ if (!mIsKeyMissingDevice && Flags.enableBluetoothDeviceDetailsPolish()) {
if (mFormatter == null) {
List controllers = getPreferenceControllers().stream()
.flatMap(List::stream)
@@ -412,12 +414,29 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
return super.onOptionsItemSelected(menuItem);
}
+ @Nullable
+ private List generateDisplayedPreferenceKeys(boolean bondingLoss) {
+ if (bondingLoss) {
+ return List.of(
+ use(BluetoothDetailsBannerController.class).getPreferenceKey(),
+ use(AdvancedBluetoothDetailsHeaderController.class).getPreferenceKey(),
+ use(BluetoothDetailsHeaderController.class).getPreferenceKey(),
+ use(LeAudioBluetoothDetailsHeaderController.class).getPreferenceKey(),
+ use(BluetoothDetailsButtonsController.class).getPreferenceKey(),
+ use(BluetoothDetailsMacAddressController.class).getPreferenceKey());
+ }
+ return null;
+ }
+
@Override
protected List createPreferenceControllers(Context context) {
ArrayList controllers = new ArrayList<>();
if (mCachedDevice != null) {
Lifecycle lifecycle = getSettingsLifecycle();
+ controllers.add(
+ new BluetoothDetailsBannerController(
+ context, this, mCachedDevice, lifecycle));
controllers.add(new BluetoothDetailsHeaderController(context, this, mCachedDevice,
lifecycle));
controllers.add(
diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsBannerControllerTest.kt b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsBannerControllerTest.kt
new file mode 100644
index 00000000000..e5c1298bfd1
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsBannerControllerTest.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+package com.android.settings.bluetooth
+
+import android.bluetooth.BluetoothDevice
+import com.android.settings.R
+import com.android.settings.testutils.FakeFeatureFactory
+import com.android.settingslib.widget.LayoutPreference
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.mockito.kotlin.whenever
+
+class BluetoothDetailsBannerControllerTest : BluetoothDetailsControllerTestBase() {
+ @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ private lateinit var controller: BluetoothDetailsBannerController
+ private lateinit var preference: LayoutPreference
+
+ override fun setUp() {
+ super.setUp()
+ FakeFeatureFactory.setupForTest()
+ controller =
+ BluetoothDetailsBannerController(mContext, mFragment, mCachedDevice, mLifecycle)
+ preference = LayoutPreference(mContext, R.layout.bluetooth_details_banner)
+ preference.key = controller.getPreferenceKey()
+ mScreen.addPreference(preference)
+ }
+
+ @Test
+ fun iaAvailable_notKeyMissing_false() {
+ setupDevice(makeDefaultDeviceConfig())
+
+ assertThat(controller.isAvailable).isFalse()
+ }
+
+ // TODO(b/379729762): add more tests after BluetoothDevice.getKeyMissingCount is available.
+}
diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsFragmentTest.kt b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsFragmentTest.kt
new file mode 100644
index 00000000000..b517fae9bca
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsFragmentTest.kt
@@ -0,0 +1,116 @@
+/*
+ * 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.
+ */
+package com.android.settings.bluetooth
+
+import android.content.Context
+import androidx.fragment.app.FragmentActivity
+import androidx.fragment.app.testing.EmptyFragmentActivity
+import androidx.preference.Preference
+import androidx.test.core.app.ActivityScenario
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.spy
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class BluetoothDetailsFragmentTest {
+ @get:Rule val mockitoRule: MockitoRule = MockitoJUnit.rule()
+
+ private lateinit var activity: FragmentActivity
+ private lateinit var fragment: TestConfigurableFragment
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ context = spy(ApplicationProvider.getApplicationContext())
+ }
+
+ @Test
+ fun setPreferenceDisplayOrder_null_unchanged() = buildFragment {
+ fragment.preferenceScreen.addPreference(Preference(context).apply { key = "key1" })
+ fragment.preferenceScreen.addPreference(Preference(context).apply { key = "key2" })
+
+ fragment.setPreferenceDisplayOrder(null)
+
+ assertThat(this.displayedKeys).containsExactly("key1", "key2")
+ }
+
+ @Test
+ fun setPreferenceDisplayOrder_hideItem() = buildFragment {
+ fragment.preferenceScreen.addPreference(Preference(context).apply { key = "key1" })
+ fragment.preferenceScreen.addPreference(Preference(context).apply { key = "key2" })
+
+ fragment.setPreferenceDisplayOrder(mutableListOf("key2"))
+
+ assertThat(this.displayedKeys).containsExactly("key2")
+ }
+
+ @Test
+ fun setPreferenceDisplayOrder_hideAndReShownItem() = buildFragment {
+ fragment.preferenceScreen.addPreference(Preference(context).apply { key = "key1" })
+ fragment.preferenceScreen.addPreference(Preference(context).apply { key = "key2" })
+
+ fragment.setPreferenceDisplayOrder(mutableListOf("key2"))
+ fragment.setPreferenceDisplayOrder(mutableListOf("key2", "key1"))
+
+ assertThat(this.displayedKeys).containsExactly("key2", "key1")
+ }
+
+ private fun buildFragment(r: (() -> Unit)) {
+ ActivityScenario.launch(EmptyFragmentActivity::class.java).use { activityScenario ->
+ activityScenario.onActivity { activity: EmptyFragmentActivity ->
+ this@BluetoothDetailsFragmentTest.activity = activity
+ fragment = TestConfigurableFragment()
+ activity.supportFragmentManager.beginTransaction().add(fragment, null).commitNow()
+ fragment.setPreferenceScreen(
+ fragment.preferenceManager.createPreferenceScreen(context)
+ )
+ r.invoke()
+ }
+ }
+ }
+
+ private val displayedKeys: List
+ get() {
+ val keys: MutableList = mutableListOf()
+ for (i in 0..