From 794fc582350c1a899bcd231705f4bfaf74496786 Mon Sep 17 00:00:00 2001 From: SongFerngWang Date: Thu, 20 Apr 2023 10:49:32 +0800 Subject: [PATCH] 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 --- res/values/config.xml | 9 + res/xml/bluetooth_device_details_fragment.xml | 10 +- .../BlockingPrefWithSliceController.java | 305 ++++++++++++++++++ .../BluetoothDeviceDetailsFragment.java | 3 +- .../BlockingPrefWithSliceControllerTest.java | 160 +++++++++ 5 files changed, 479 insertions(+), 8 deletions(-) create mode 100644 src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java create mode 100644 tests/unit/src/com/android/settings/bluetooth/BlockingPrefWithSliceControllerTest.java diff --git a/res/values/config.xml b/res/values/config.xml index 7444b576e9e..4e2c55478c6 100755 --- a/res/values/config.xml +++ b/res/values/config.xml @@ -491,6 +491,15 @@ content://com.google.android.gms.nearby.fastpair/device_status_list_item + + + + + + + + + diff --git a/res/xml/bluetooth_device_details_fragment.xml b/res/xml/bluetooth_device_details_fragment.xml index 0528973d676..40217158004 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"/> - + @@ -91,4 +89,4 @@ settings:searchable="false" settings:controller="com.android.settings.bluetooth.BluetoothDetailsMacAddressController"/> - \ No newline at end of file + diff --git a/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java b/src/com/android/settings/bluetooth/BlockingPrefWithSliceController.java new file mode 100644 index 00000000000..b443047d7e8 --- /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, BasePreferenceController.UiBlocker{ + private static final String TAG = "BlockingPrefWithSliceController"; + + private static final String PREFIX_KEY = "slice_preference_item_"; + + @VisibleForTesting + LiveData mLiveData; + private Uri mUri; + @VisibleForTesting + PreferenceCategory mPreferenceCategory; + private List 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 parseSliceToPreferenceList(Slice slice) { + List preferenceItemsList = new ArrayList<>(); + List items = slice.getItems(); + int orderLevel = 0; + for (SliceItem sliceItem : items) { + // Parse the slice + if (sliceItem.getFormat().equals(FORMAT_SLICE)) { + Optional title = extractTitleFromSlice(sliceItem.getSlice()); + Optional subtitle = extractSubtitleFromSlice(sliceItem.getSlice()); + Optional action = extractActionFromSlice(sliceItem.getSlice()); + // Create preference + Optional preferenceItem = createPreferenceItem(title, subtitle, action, + orderLevel); + if (preferenceItem.isPresent()) { + orderLevel++; + preferenceItemsList.add(preferenceItem.get()); + } + } + } + return preferenceItemsList; + } + + private Optional createPreferenceItem(Optional title, + Optional subtitle, Optional 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 newPreferenceList) { + List 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 extractTitleFromSlice(Slice slice) { + return extractTextFromSlice(slice, HINT_TITLE); + } + + private Optional extractSubtitleFromSlice(Slice slice) { + // For subtitle items, there isn't a hint available. + return extractTextFromSlice(slice, /* hint= */ null); + } + + private Optional 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 extractActionFromSlice(Slice slice) { + for (SliceItem item : slice.getItems()) { + if (item.getFormat().equals(FORMAT_SLICE)) { + if (item.hasHint(HINT_TITLE)) { + Optional result = extractActionFromSlice(item.getSlice()); + if (result.isPresent()) { + return result; + } + } + continue; + } + + if (item.getFormat().equals(FORMAT_ACTION)) { + Optional icon = extractIconFromSlice(item.getSlice()); + Optional 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 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 fa15b5ca336..562ffec17a0 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.R; 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 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment 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 00000000000..65b6977116d --- /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 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(); + } +}