From 519f1752f0b8d795328bf14103fb71cc0ddb1a48 Mon Sep 17 00:00:00 2001 From: Haijie Hong Date: Fri, 14 Mar 2025 14:55:33 +0800 Subject: [PATCH] Show bond loss UI in device details Bug: 380801155 Test: atest BluetoothDetailsFragmentTest Flag: EXEMPT minor change Change-Id: I458778e1a3adde4ec1ddd8b84b8dc7f1d91621f5 --- .../bluetooth_details_banner_background.xml | 32 +++++ res/layout/bluetooth_details_banner.xml | 50 ++++++++ res/xml/bluetooth_device_details_fragment.xml | 7 ++ .../BluetoothDetailsBannerController.kt | 54 ++++++++ .../BluetoothDetailsConfigurableFragment.kt | 95 ++++++++++++++ .../BluetoothDetailsHeaderController.java | 1 - .../BluetoothDeviceDetailsFragment.java | 29 ++++- .../BluetoothDetailsBannerControllerTest.kt | 54 ++++++++ .../bluetooth/BluetoothDetailsFragmentTest.kt | 116 ++++++++++++++++++ 9 files changed, 432 insertions(+), 6 deletions(-) create mode 100644 res/drawable/bluetooth_details_banner_background.xml create mode 100644 res/layout/bluetooth_details_banner.xml create mode 100644 src/com/android/settings/bluetooth/BluetoothDetailsBannerController.kt create mode 100644 src/com/android/settings/bluetooth/BluetoothDetailsConfigurableFragment.kt create mode 100644 tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsBannerControllerTest.kt create mode 100644 tests/robotests/src/com/android/settings/bluetooth/BluetoothDetailsFragmentTest.kt 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..