Merge "Show bond loss UI in device details" into main

This commit is contained in:
Haijie Hong
2025-03-19 00:22:21 -07:00
committed by Android (Google) Code Review
9 changed files with 432 additions and 6 deletions

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2025 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?android:colorControlHighlight">
<item
android:start="16dp"
android:end="16dp"
android:top="16dp"
android:bottom="16dp">
<shape android:shape="rectangle">
<solid
android:color="@color/settingslib_materialColorSurfaceVariant" />
<corners
android:radius="28dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2025 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="36dp"
android:orientation="vertical"
android:background="@drawable/bluetooth_details_banner_background">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="start|top"
android:orientation="horizontal"
android:paddingBottom="8dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/settingslib_ic_info_outline_24"
android:tint="@color/settingslib_materialColorOnSurfaceVariant"
android:importantForAccessibility="no" />
</LinearLayout>
<TextView
android:id="@+id/bluetooth_details_banner_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:textAlignment="viewStart"
android:textColor="@color/settingslib_materialColorOnSurfaceVariant"
android:hyphenationFrequency="normalFast"
android:lineBreakWordStyle="phrase"
android:ellipsize="marquee" />
</LinearLayout>

View File

@@ -19,6 +19,13 @@
xmlns:settings="http://schemas.android.com/apk/res-auto"
android:title="@string/device_details_title">
<com.android.settingslib.widget.LayoutPreference
android:key="bluetooth_details_banner"
android:layout="@layout/bluetooth_details_banner"
android:selectable="false"
settings:allowDividerBelow="true"
settings:searchable="false"/>
<com.android.settingslib.widget.LayoutPreference
android:key="bluetooth_device_header"
android:layout="@layout/settings_entity_header"

View File

@@ -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.content.Context
import android.widget.TextView
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
import com.android.settings.R
import com.android.settingslib.bluetooth.BluetoothUtils
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.core.lifecycle.Lifecycle
import com.android.settingslib.widget.LayoutPreference
class BluetoothDetailsBannerController(
private val context: Context,
fragment: PreferenceFragmentCompat,
private val cachedDevice: CachedBluetoothDevice,
lifecycle: Lifecycle,
) : BluetoothDetailsController(context, fragment, cachedDevice, lifecycle) {
private lateinit var pref: LayoutPreference
override fun getPreferenceKey(): String = KEY_BLUETOOTH_DETAILS_BANNER
override fun init(screen: PreferenceScreen) {
pref = screen.findPreference(KEY_BLUETOOTH_DETAILS_BANNER) ?: return
}
override fun refresh() {
pref.findViewById<TextView>(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"
}
}

View File

@@ -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<String>? = null
fun setPreferenceDisplayOrder(prefKeyOrder: List<String>?) {
if (displayOrder == prefKeyOrder) {
return
}
displayOrder = prefKeyOrder
updatePreferenceOrder()
}
private val invisiblePrefCategory: PreferenceGroup by lazy {
preferenceScreen.findPreference<PreferenceGroup>(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<Preference> {
val prefs = mutableListOf<Preference>()
for (i in 0..<preferenceCount) {
prefs.add(getPreference(i))
}
removeAll()
return prefs
}
private fun PreferenceGroup.addPreferences(prefs: List<Preference>) {
for (pref in prefs) {
addPreference(pref)
}
}
private companion object {
const val INVISIBLE_CATEGORY = "invisible_profile_category"
}
}

View File

@@ -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() {

View File

@@ -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<AbstractPreferenceController> controllers = getPreferenceControllers().stream()
.flatMap(List::stream)
@@ -412,12 +414,29 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment
return super.onOptionsItemSelected(menuItem);
}
@Nullable
private List<String> 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<AbstractPreferenceController> createPreferenceControllers(Context context) {
ArrayList<AbstractPreferenceController> 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(

View File

@@ -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.
}

View File

@@ -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<Context>())
}
@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<String>
get() {
val keys: MutableList<String> = mutableListOf()
for (i in 0..<fragment.preferenceScreen.preferenceCount) {
if (fragment.preferenceScreen.getPreference(i).isVisible) {
keys.add(fragment.preferenceScreen.getPreference(i).key)
}
}
return keys
}
class TestConfigurableFragment : BluetoothDetailsConfigurableFragment() {
protected override fun getPreferenceScreenResId(): Int {
return 0
}
override fun getLogTag(): String {
return "TAG"
}
override fun getMetricsCategory(): Int {
return 0
}
}
}