Replace the SlicePreference with Preference
The Settings' 2 panel did not support SlicePreference, since it
always open activity with NEW_TASK and it casues the settings can't
set new page at right side.
Bug: 270544054
Test: build pass. local test: the phone pair the buds with fastpair, and
then check the slice preferences.
atest BlockingPrefWithSliceControllerTest (pass)
Change-Id: I0e8abfd284492f04ab322a5bed13741fc6b25b34
diff --git a/res/values/config.xml b/res/values/config.xml
index 7444b57..4e2c554 100755
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -491,6 +491,15 @@
<!-- Slice Uri to query nearby devices. -->
<string name="config_nearby_devices_slice_uri" translatable="false">content://com.google.android.gms.nearby.fastpair/device_status_list_item</string>
+ <!-- BT Slice intent action. To support Settings 2 panel, BT slice can't use PendingIntent.send(). Therefore, here defines the Slice intent action. -->
+ <string name="config_bt_slice_intent_action" translatable="false"></string>
+ <!-- BT Slice pending intent action. To support Settings 2 panel, BT slice can't use PendingIntent.send(). Therefore, here defines the Slice pending intent action. -->
+ <string name="config_bt_slice_pending_intent_action" translatable="false"></string>
+ <!-- BT Slice EXTRA_INTENT. To support Settings 2 panel, BT slice can't use PendingIntent.send(). Therefore, here defines the Slice EXTRA_INTENT. -->
+ <string name="config_bt_slice_extra_intent" translatable="false"></string>
+ <!-- BT Slice EXTRA_PENDING_INTENT. To support Settings 2 panel, BT slice can't use PendingIntent.send(). Therefore, here defines the Slice EXTRA_PENDING_INTENT. -->
+ <string name="config_bt_slice_extra_pending_intent" translatable="false"></string>
+
<!-- Grayscale settings intent -->
<string name="config_grayscale_settings_intent" translatable="false"></string>
diff --git a/res/xml/bluetooth_device_details_fragment.xml b/res/xml/bluetooth_device_details_fragment.xml
index 0528973..4021715 100644
--- a/res/xml/bluetooth_device_details_fragment.xml
+++ b/res/xml/bluetooth_device_details_fragment.xml
@@ -58,11 +58,9 @@
settings:controller="com.android.settings.slices.SlicePreferenceController"
settings:allowDividerAbove="true"/>
- <com.android.settings.slices.SlicePreference
- android:key="bt_device_slice"
- settings:controller="com.android.settings.slices.BlockingSlicePrefController"
- settings:allowDividerBelow="true"
- settings:allowDividerAbove="true"/>
+ <PreferenceCategory
+ android:key="bt_device_slice_category"
+ settings:controller="com.android.settings.bluetooth.BlockingPrefWithSliceController"/>
<PreferenceCategory
android:key="device_companion_apps"/>
@@ -91,4 +89,4 @@
settings:searchable="false"
settings:controller="com.android.settings.bluetooth.BluetoothDetailsMacAddressController"/>
-</PreferenceScreen>
\ No newline at end of file
+</PreferenceScreen>
diff --git a/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java b/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java
new file mode 100644
index 0000000..b443047
--- /dev/null
+++ b/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java
@@ -0,0 +1,305 @@
+/*
+ * Copyright (C) 2023 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 static android.app.slice.Slice.HINT_PERMISSION_REQUEST;
+import static android.app.slice.Slice.HINT_TITLE;
+import static android.app.slice.SliceItem.FORMAT_ACTION;
+import static android.app.slice.SliceItem.FORMAT_IMAGE;
+import static android.app.slice.SliceItem.FORMAT_SLICE;
+import static android.app.slice.SliceItem.FORMAT_TEXT;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.graphics.drawable.IconCompat;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.Observer;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.preference.PreferenceScreen;
+import androidx.slice.Slice;
+import androidx.slice.SliceItem;
+import androidx.slice.builders.ListBuilder;
+import androidx.slice.builders.SliceAction;
+import androidx.slice.widget.SliceLiveData;
+
+import com.android.settings.R;
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.core.lifecycle.LifecycleObserver;
+import com.android.settingslib.core.lifecycle.events.OnStart;
+import com.android.settingslib.core.lifecycle.events.OnStop;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * The blocking preference with slice controller will make whole page invisible for a certain time
+ * until {@link Slice} is fully loaded.
+ */
+public class BlockingPrefWithSliceController extends BasePreferenceController implements
+ LifecycleObserver, OnStart, OnStop, Observer<Slice>, BasePreferenceController.UiBlocker{
+ private static final String TAG = "BlockingPrefWithSliceController";
+
+ private static final String PREFIX_KEY = "slice_preference_item_";
+
+ @VisibleForTesting
+ LiveData<Slice> mLiveData;
+ private Uri mUri;
+ @VisibleForTesting
+ PreferenceCategory mPreferenceCategory;
+ private List<Preference> mCurrentPreferencesList = new ArrayList<>();
+ @VisibleForTesting
+ String mSliceIntentAction = "";
+ @VisibleForTesting
+ String mSlicePendingIntentAction = "";
+ @VisibleForTesting
+ String mExtraIntent = "";
+ @VisibleForTesting
+ String mExtraPendingIntent = "";
+
+ public BlockingPrefWithSliceController(Context context, String preferenceKey) {
+ super(context, preferenceKey);
+ }
+
+ @Override
+ public void displayPreference(PreferenceScreen screen) {
+ super.displayPreference(screen);
+ mPreferenceCategory = screen.findPreference(getPreferenceKey());
+ mSliceIntentAction = mContext.getResources().getString(
+ R.string.config_bt_slice_intent_action);
+ mSlicePendingIntentAction = mContext.getResources().getString(
+ R.string.config_bt_slice_pending_intent_action);
+ mExtraIntent = mContext.getResources().getString(R.string.config_bt_slice_extra_intent);
+ mExtraPendingIntent = mContext.getResources().getString(
+ R.string.config_bt_slice_extra_pending_intent);
+ }
+
+ @Override
+ public int getAvailabilityStatus() {
+ return mUri != null ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
+ }
+
+ public void setSliceUri(Uri uri) {
+ mUri = uri;
+ mLiveData = SliceLiveData.fromUri(mContext, mUri, (int type, Throwable source) -> {
+ Log.w(TAG, "Slice may be null. uri = " + uri + ", error = " + type);
+ });
+
+ //TODO(b/120803703): figure out why we need to remove observer first
+ mLiveData.removeObserver(this);
+ }
+
+ @Override
+ public void onStart() {
+ if (mLiveData != null) {
+ mLiveData.observeForever(this);
+ }
+ }
+
+ @Override
+ public void onStop() {
+ if (mLiveData != null) {
+ mLiveData.removeObserver(this);
+ }
+ }
+
+ @Override
+ public void onChanged(Slice slice) {
+ updatePreferenceFromSlice(slice);
+ if (mUiBlockListener != null) {
+ mUiBlockListener.onBlockerWorkFinished(this);
+ }
+ }
+
+ @VisibleForTesting
+ void updatePreferenceFromSlice(Slice slice) {
+ if (TextUtils.isEmpty(mSliceIntentAction)
+ || TextUtils.isEmpty(mExtraIntent)
+ || TextUtils.isEmpty(mSlicePendingIntentAction)
+ || TextUtils.isEmpty(mExtraPendingIntent)) {
+ Log.d(TAG, "No configs");
+ return;
+ }
+ if (slice == null || slice.hasHint(HINT_PERMISSION_REQUEST)) {
+ Log.d(TAG, "Current slice: " + slice);
+ removePreferenceListFromPreferenceCategory();
+ return;
+ }
+ updatePreferenceListAndPreferenceCategory(parseSliceToPreferenceList(slice));
+ }
+
+ private List<Preference> parseSliceToPreferenceList(Slice slice) {
+ List<Preference> preferenceItemsList = new ArrayList<>();
+ List<SliceItem> items = slice.getItems();
+ int orderLevel = 0;
+ for (SliceItem sliceItem : items) {
+ // Parse the slice
+ if (sliceItem.getFormat().equals(FORMAT_SLICE)) {
+ Optional<CharSequence> title = extractTitleFromSlice(sliceItem.getSlice());
+ Optional<CharSequence> subtitle = extractSubtitleFromSlice(sliceItem.getSlice());
+ Optional<SliceAction> action = extractActionFromSlice(sliceItem.getSlice());
+ // Create preference
+ Optional<Preference> preferenceItem = createPreferenceItem(title, subtitle, action,
+ orderLevel);
+ if (preferenceItem.isPresent()) {
+ orderLevel++;
+ preferenceItemsList.add(preferenceItem.get());
+ }
+ }
+ }
+ return preferenceItemsList;
+ }
+
+ private Optional<Preference> createPreferenceItem(Optional<CharSequence> title,
+ Optional<CharSequence> subtitle, Optional<SliceAction> sliceAction, int orderLevel) {
+ Log.d(TAG, "Title: " + title.orElse("no title")
+ + ", Subtitle: " + subtitle.orElse("no Subtitle")
+ + ", Action: " + sliceAction.orElse(null));
+ if (!title.isPresent()) {
+ return Optional.empty();
+ }
+ String key = PREFIX_KEY + title.get();
+ Preference preference = mPreferenceCategory.findPreference(key);
+ if (preference == null) {
+ preference = new Preference(mContext);
+ preference.setKey(key);
+ mPreferenceCategory.addPreference(preference);
+ }
+ preference.setTitle(title.get());
+ preference.setOrder(orderLevel);
+ if (subtitle.isPresent()) {
+ preference.setSummary(subtitle.get());
+ }
+ if (sliceAction.isPresent()) {
+ // To support the settings' 2 panel feature, here can't use the slice's
+ // PendingIntent.send(). Since the PendingIntent.send() always take NEW_TASK flag.
+ // Therefore, transfer the slice's PendingIntent to Intent and start it
+ // without NEW_TASK.
+ preference.setIcon(sliceAction.get().getIcon().loadDrawable(mContext));
+ Intent intentFromSliceAction = sliceAction.get().getAction().getIntent();
+ Intent expectedActivityIntent = null;
+ Log.d(TAG, "SliceAction: intent's Action:" + intentFromSliceAction.getAction());
+ if (intentFromSliceAction.getAction().equals(mSliceIntentAction)) {
+ expectedActivityIntent = intentFromSliceAction
+ .getParcelableExtra(mExtraIntent, Intent.class);
+ } else if (intentFromSliceAction.getAction().equals(
+ mSlicePendingIntentAction)) {
+ PendingIntent pendingIntent = intentFromSliceAction
+ .getParcelableExtra(mExtraPendingIntent, PendingIntent.class);
+ expectedActivityIntent =
+ pendingIntent != null ? pendingIntent.getIntent() : null;
+ } else {
+ expectedActivityIntent = intentFromSliceAction;
+ }
+ if (expectedActivityIntent != null) {
+ Log.d(TAG, "setIntent: ActivityIntent" + expectedActivityIntent);
+ // Since UI needs to support the Settings' 2 panel feature, the intent can't use the
+ // FLAG_ACTIVITY_NEW_TASK. The above intent may have the FLAG_ACTIVITY_NEW_TASK
+ // flag, so removes it before startActivity(preference.setIntent).
+ expectedActivityIntent.removeFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ preference.setIntent(expectedActivityIntent);
+ } else {
+ Log.d(TAG, "setIntent: Intent is null");
+ }
+ }
+
+ return Optional.of(preference);
+ }
+
+ private void removePreferenceListFromPreferenceCategory() {
+ mCurrentPreferencesList.stream()
+ .forEach(p -> mPreferenceCategory.removePreference(p));
+ mCurrentPreferencesList.clear();
+ }
+
+ private void updatePreferenceListAndPreferenceCategory(List<Preference> newPreferenceList) {
+ List<Preference> removedItemList = new ArrayList<>(mCurrentPreferencesList);
+ for (Preference item : mCurrentPreferencesList) {
+ if (newPreferenceList.stream().anyMatch(p -> item.compareTo(p) == 0)) {
+ removedItemList.remove(item);
+ }
+ }
+ removedItemList.stream()
+ .forEach(p -> mPreferenceCategory.removePreference(p));
+ mCurrentPreferencesList = newPreferenceList;
+ }
+
+ private Optional<CharSequence> extractTitleFromSlice(Slice slice) {
+ return extractTextFromSlice(slice, HINT_TITLE);
+ }
+
+ private Optional<CharSequence> extractSubtitleFromSlice(Slice slice) {
+ // For subtitle items, there isn't a hint available.
+ return extractTextFromSlice(slice, /* hint= */ null);
+ }
+
+ private Optional<CharSequence> extractTextFromSlice(Slice slice, @Nullable String hint) {
+ for (SliceItem item : slice.getItems()) {
+ if (item.getFormat().equals(FORMAT_TEXT)
+ && ((TextUtils.isEmpty(hint) && item.getHints().isEmpty())
+ || (!TextUtils.isEmpty(hint) && item.hasHint(hint)))) {
+ return Optional.ofNullable(item.getText());
+ }
+ }
+ return Optional.empty();
+ }
+
+ private Optional<SliceAction> extractActionFromSlice(Slice slice) {
+ for (SliceItem item : slice.getItems()) {
+ if (item.getFormat().equals(FORMAT_SLICE)) {
+ if (item.hasHint(HINT_TITLE)) {
+ Optional<SliceAction> result = extractActionFromSlice(item.getSlice());
+ if (result.isPresent()) {
+ return result;
+ }
+ }
+ continue;
+ }
+
+ if (item.getFormat().equals(FORMAT_ACTION)) {
+ Optional<IconCompat> icon = extractIconFromSlice(item.getSlice());
+ Optional<CharSequence> title = extractTitleFromSlice(item.getSlice());
+ if (icon.isPresent()) {
+ return Optional.of(
+ SliceAction.create(
+ item.getAction(),
+ icon.get(),
+ ListBuilder.ICON_IMAGE,
+ title.orElse(/* other= */ "")));
+ }
+ }
+ }
+ return Optional.empty();
+ }
+
+ private Optional<IconCompat> extractIconFromSlice(Slice slice) {
+ for (SliceItem item : slice.getItems()) {
+ if (item.getFormat().equals(FORMAT_IMAGE)) {
+ return Optional.of(item.getIcon());
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
index fa15b5c..562ffec 100644
--- a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
+++ b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
@@ -42,7 +42,6 @@
import com.android.settings.core.SettingsUIDeviceConfig;
import com.android.settings.dashboard.RestrictedDashboardFragment;
import com.android.settings.overlay.FeatureFactory;
-import com.android.settings.slices.BlockingSlicePrefController;
import com.android.settings.slices.SlicePreferenceController;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
@@ -133,7 +132,7 @@
final boolean sliceEnabled = DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SETTINGS_UI,
SettingsUIDeviceConfig.BT_SLICE_SETTINGS_ENABLED, true);
- use(BlockingSlicePrefController.class).setSliceUri(sliceEnabled
+ use(BlockingPrefWithSliceController.class).setSliceUri(sliceEnabled
? featureProvider.getBluetoothDeviceSettingsUri(mCachedDevice.getDevice())
: null);
}
diff --git a/tests/unit/src/com/android/settings/bluetooth/BlockingPrefWithSliceControllerTest.java b/tests/unit/src/com/android/settings/bluetooth/BlockingPrefWithSliceControllerTest.java
new file mode 100644
index 0000000..65b6977
--- /dev/null
+++ b/tests/unit/src/com/android/settings/bluetooth/BlockingPrefWithSliceControllerTest.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2023 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 static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.ContentResolver;
+import android.content.Intent;
+import android.net.Uri;
+
+import androidx.core.graphics.drawable.IconCompat;
+import androidx.lifecycle.LiveData;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceCategory;
+import androidx.slice.Slice;
+import androidx.slice.SliceViewManager;
+import androidx.slice.builders.ListBuilder;
+import androidx.slice.builders.ListBuilder.RowBuilder;
+import androidx.slice.builders.SliceAction;
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import com.android.settings.bluetooth.BlockingPrefWithSliceController;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class BlockingPrefWithSliceControllerTest {
+ private static final String KEY = "bt_device_slice_category";
+ private static final String TEST_URI_AUTHORITY = "com.android.authority.test";
+ private static final String TEST_EXTRA_INTENT = "EXTRA_INTENT";
+ private static final String TEST_EXTRA_PENDING_INTENT = "EXTRA_PENDING_INTENT";
+ private static final String TEST_INTENT_ACTION = "test";
+ private static final String TEST_PENDING_INTENT_ACTION = "test";
+ private static final String TEST_SLICE_TITLE = "Test Title";
+ private static final String TEST_SLICE_SUBTITLE = "Test Subtitle";
+ private static final String FAKE_ACTION = "fake_action";
+
+ @Rule
+ public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+
+ @Mock
+ private LiveData<Slice> mLiveData;
+ @Mock
+ private PreferenceCategory mPreferenceCategory;
+
+ private Context mContext;
+ private BlockingPrefWithSliceController mController;
+ private Uri mUri;
+
+ @Before
+ public void setUp() {
+ mContext = spy(ApplicationProvider.getApplicationContext());
+ mController = spy(new BlockingPrefWithSliceController(mContext, KEY));
+ mController.mLiveData = mLiveData;
+ mController.mExtraIntent = TEST_EXTRA_INTENT;
+ mController.mExtraPendingIntent = TEST_EXTRA_PENDING_INTENT;
+ mController.mSliceIntentAction = TEST_INTENT_ACTION;
+ mController.mSlicePendingIntentAction = TEST_PENDING_INTENT_ACTION;
+ mController.mPreferenceCategory = mPreferenceCategory;
+ mUri = Uri.EMPTY;
+ }
+
+ @Test
+ public void isAvailable_uriNull_returnFalse() {
+ assertThat(mController.isAvailable()).isFalse();
+ }
+
+ @Test
+ @UiThreadTest
+ public void isAvailable_uriNotNull_returnTrue() {
+ mController.setSliceUri(mUri);
+
+ assertThat(mController.isAvailable()).isTrue();
+ }
+
+ @Test
+ public void onStart_registerObserver() {
+ mController.onStart();
+
+ verify(mLiveData).observeForever(mController);
+ }
+
+ @Test
+ public void onStop_unregisterObserver() {
+ mController.onStop();
+
+ verify(mLiveData).removeObserver(mController);
+ }
+
+ @Test
+ public void onChanged_nullSlice_updateSlice() {
+ mController.onChanged(null);
+
+ verify(mController).updatePreferenceFromSlice(null);
+ }
+
+ @Test
+ public void onChanged_testSlice_updateSlice() {
+ mController.onChanged(buildTestSlice());
+
+ verify(mController.mPreferenceCategory).addPreference(any());
+ }
+
+ private Slice buildTestSlice() {
+ Uri uri =
+ new Uri.Builder()
+ .scheme(ContentResolver.SCHEME_CONTENT)
+ .authority(TEST_URI_AUTHORITY)
+ .build();
+ SliceViewManager.getInstance(mContext).pinSlice(uri);
+ ListBuilder listBuilder = new ListBuilder(mContext, uri, ListBuilder.INFINITY);
+ IconCompat icon = mock(IconCompat.class);
+ listBuilder.addRow(
+ new RowBuilder()
+ .setTitleItem(icon, ListBuilder.ICON_IMAGE)
+ .setTitle(TEST_SLICE_TITLE)
+ .setSubtitle(TEST_SLICE_SUBTITLE)
+ .setPrimaryAction(
+ SliceAction.create(
+ PendingIntent.getActivity(
+ mContext,
+ /*requestCode= */ 0,
+ new Intent(FAKE_ACTION),
+ PendingIntent.FLAG_UPDATE_CURRENT
+ | PendingIntent.FLAG_IMMUTABLE),
+ icon,
+ ListBuilder.ICON_IMAGE,
+ "")));
+ return listBuilder.build();
+ }
+}