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();
+    }
+}