From e92bc55d12f173273f5aee725cd5f978f2fc9066 Mon Sep 17 00:00:00 2001 From: Steven Ng Date: Wed, 10 Feb 2021 17:10:15 +0000 Subject: [PATCH] Make all widgets collapsed in the full widget picker by default Changes: 1. Add a WidgetListHeader view for showing icon, app name and a subtitle. 2. Only WidgetListHeaders are always visible to users in the full widget picker. 3. Only one widgets list from an app is visible in the full widget picker at any one time. Test: Auto: run add robolectric tests under widget/picker Manual: Open full widgets picker. Then, expand and collapse apps. Video: https://drive.google.com/file/d/1gzfeEm5IOAu0qHsO77OTS2eMfU7CHJiL/view?usp=sharing Bug: 179797520 Change-Id: Idac58be23dfeafcb79b3c61b4972d3addb462de1 --- res/drawable/ic_expand_less.xml | 25 ++ res/drawable/ic_expand_more.xml | 25 ++ res/drawable/widgets_tray_expand_button.xml | 21 ++ res/layout/widgets_list_row_header.xml | 71 +++++ res/values/attrs.xml | 4 + res/values/strings.xml | 5 + .../picker/WidgetsDiffReporterTest.java | 269 ++++++++++++++++++ .../widget/picker/WidgetsListAdapterTest.java | 109 ++++--- ...WidgetsListHeaderViewHolderBinderTest.java | 173 +++++++++++ .../WidgetsListRowViewHolderBinderTest.java | 13 - src/com/android/launcher3/BubbleTextView.java | 28 +- .../graphics/PlaceHolderIconDrawable.java | 32 +++ .../widget/model/WidgetsListBaseEntry.java | 24 ++ .../widget/model/WidgetsListContentEntry.java | 6 + .../widget/model/WidgetsListHeaderEntry.java | 64 +++++ .../widget/picker/WidgetsDiffReporter.java | 47 ++- .../widget/picker/WidgetsListAdapter.java | 69 ++++- .../widget/picker/WidgetsListHeader.java | 205 +++++++++++++ .../picker/WidgetsListHeaderHolder.java | 32 +++ .../WidgetsListHeaderViewHolderBinder.java | 61 ++++ .../WidgetsListRowViewHolderBinder.java | 5 +- .../widget/picker/WidgetsRowViewHolder.java | 6 +- .../android/launcher3/model/WidgetsModel.java | 7 +- .../ui/widget/AddConfigWidgetTest.java | 5 +- .../com/android/launcher3/tapl/Widgets.java | 72 ++++- 25 files changed, 1254 insertions(+), 124 deletions(-) create mode 100644 res/drawable/ic_expand_less.xml create mode 100644 res/drawable/ic_expand_more.xml create mode 100644 res/drawable/widgets_tray_expand_button.xml create mode 100644 res/layout/widgets_list_row_header.xml create mode 100644 robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java create mode 100644 robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java create mode 100644 src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java create mode 100644 src/com/android/launcher3/widget/picker/WidgetsListHeader.java create mode 100644 src/com/android/launcher3/widget/picker/WidgetsListHeaderHolder.java create mode 100644 src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinder.java diff --git a/res/drawable/ic_expand_less.xml b/res/drawable/ic_expand_less.xml new file mode 100644 index 0000000000..8360cee487 --- /dev/null +++ b/res/drawable/ic_expand_less.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/res/drawable/ic_expand_more.xml b/res/drawable/ic_expand_more.xml new file mode 100644 index 0000000000..49e24f6ba7 --- /dev/null +++ b/res/drawable/ic_expand_more.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/res/drawable/widgets_tray_expand_button.xml b/res/drawable/widgets_tray_expand_button.xml new file mode 100644 index 0000000000..8316e0fbe3 --- /dev/null +++ b/res/drawable/widgets_tray_expand_button.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/res/layout/widgets_list_row_header.xml b/res/layout/widgets_list_row_header.xml new file mode 100644 index 0000000000..faff10c156 --- /dev/null +++ b/res/layout/widgets_list_row_header.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/values/attrs.xml b/res/values/attrs.xml index e593fb497d..b19ea22858 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -185,4 +185,8 @@ + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml index 447c9ac568..c30019b930 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -54,6 +54,11 @@ Touch & hold to place manually Add automatically + + + %1$d widget + %1$d widgets + diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java new file mode 100644 index 0000000000..04797a62fb --- /dev/null +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2021 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.launcher3.widget.picker; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.robolectric.Shadows.shadowOf; + +import android.appwidget.AppWidgetProviderInfo; +import android.content.ComponentName; +import android.content.Context; +import android.graphics.Bitmap; +import android.os.UserHandle; + +import androidx.recyclerview.widget.RecyclerView; + +import com.android.launcher3.InvariantDeviceProfile; +import com.android.launcher3.LauncherAppWidgetProviderInfo; +import com.android.launcher3.icons.BitmapInfo; +import com.android.launcher3.icons.ComponentWithLabel; +import com.android.launcher3.icons.IconCache; +import com.android.launcher3.model.WidgetItem; +import com.android.launcher3.model.data.PackageItemInfo; +import com.android.launcher3.widget.model.WidgetsListBaseEntry; +import com.android.launcher3.widget.model.WidgetsListContentEntry; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; +import com.android.launcher3.widget.picker.WidgetsListAdapter.WidgetListBaseRowEntryComparator; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.shadows.ShadowPackageManager; +import org.robolectric.util.ReflectionHelpers; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public final class WidgetsDiffReporterTest { + private static final String TEST_PACKAGE_PREFIX = "com.google.test"; + private static final WidgetListBaseRowEntryComparator COMPARATOR = + new WidgetListBaseRowEntryComparator(); + + @Mock private IconCache mIconCache; + @Mock private RecyclerView.Adapter mAdapter; + + private InvariantDeviceProfile mTestProfile; + private WidgetsDiffReporter mWidgetsDiffReporter; + private Context mContext; + private WidgetsListHeaderEntry mHeaderA; + private WidgetsListHeaderEntry mHeaderB; + private WidgetsListHeaderEntry mHeaderC; + private WidgetsListHeaderEntry mHeaderD; + private WidgetsListHeaderEntry mHeaderE; + private WidgetsListContentEntry mContentC; + private WidgetsListContentEntry mContentE; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mTestProfile = new InvariantDeviceProfile(); + mTestProfile.numRows = 5; + mTestProfile.numColumns = 5; + + doAnswer(invocation -> ((ComponentWithLabel) invocation.getArgument(0)) + .getComponent().getPackageName()) + .when(mIconCache).getTitleNoCache(any()); + + mContext = RuntimeEnvironment.application; + mWidgetsDiffReporter = new WidgetsDiffReporter(mIconCache, mAdapter); + mHeaderA = createWidgetsHeaderEntry(TEST_PACKAGE_PREFIX + "A", + /* appName= */ "A", /* numOfWidgets= */ 3); + mHeaderB = createWidgetsHeaderEntry(TEST_PACKAGE_PREFIX + "B", + /* appName= */ "B", /* numOfWidgets= */ 3); + mHeaderC = createWidgetsHeaderEntry(TEST_PACKAGE_PREFIX + "C", + /* appName= */ "C", /* numOfWidgets= */ 3); + mContentC = createWidgetsContentEntry(TEST_PACKAGE_PREFIX + "C", + /* appName= */ "C", /* numOfWidgets= */ 3); + mHeaderD = createWidgetsHeaderEntry(TEST_PACKAGE_PREFIX + "D", + /* appName= */ "D", /* numOfWidgets= */ 3); + mHeaderE = createWidgetsHeaderEntry(TEST_PACKAGE_PREFIX + "E", + /* appName= */ "E", /* numOfWidgets= */ 3); + mContentE = createWidgetsContentEntry(TEST_PACKAGE_PREFIX + "E", + /* appName= */ "E", /* numOfWidgets= */ 3); + } + + @Test + public void listNotChanged_shouldNotInvokeAnyCallbacks() { + // GIVEN the current list has app headers [A, B, C]. + ArrayList currentList = new ArrayList<>( + List.of(mHeaderA, mHeaderB, mHeaderC)); + + // WHEN computing the list difference. + mWidgetsDiffReporter.process(currentList, currentList, COMPARATOR); + + // THEN there is no adaptor callback. + verifyZeroInteractions(mAdapter); + // THEN the current list contains the same entries. + assertThat(currentList).containsExactly(mHeaderA, mHeaderB, mHeaderC); + } + + @Test + public void headersOnly_emptyListToNonEmpty_shouldInvokeNotifyDataSetChanged() { + // GIVEN the current list has app headers [A, B, C]. + ArrayList currentList = new ArrayList<>(); + + List newList = List.of( + createWidgetsHeaderEntry(TEST_PACKAGE_PREFIX + "A", "A", 3), + createWidgetsHeaderEntry(TEST_PACKAGE_PREFIX + "B", "B", 3), + createWidgetsHeaderEntry(TEST_PACKAGE_PREFIX + "C", "C", 3)); + + // WHEN computing the list difference. + mWidgetsDiffReporter.process(currentList, newList, COMPARATOR); + + // THEN notifyDataSetChanged is called + verify(mAdapter).notifyDataSetChanged(); + // THEN the current list contains all elements from the new list. + assertThat(currentList).containsExactlyElementsIn(newList); + } + + @Test + public void headersOnly_nonEmptyToEmptyList_shouldInvokeNotifyDataSetChanged() { + // GIVEN the current list has app headers [A, B, C]. + ArrayList currentList = new ArrayList<>( + List.of(mHeaderA, mHeaderB, mHeaderC)); + // GIVEN the new list is empty. + List newList = List.of(); + + // WHEN computing the list difference. + mWidgetsDiffReporter.process(currentList, newList, COMPARATOR); + + // THEN notifyDataSetChanged is called. + verify(mAdapter).notifyDataSetChanged(); + // THEN the current list isEmpty. + assertThat(currentList).isEmpty(); + } + + @Test + public void headersOnly_itemAddedAndRemovedInTheNewList_shouldInvokeCorrectCallbacks() { + // GIVEN the current list has app headers [A, B, D]. + ArrayList currentList = new ArrayList<>( + List.of(mHeaderA, mHeaderB, mHeaderD)); + // GIVEN the new list has app headers [A, C, E]. + List newList = List.of(mHeaderA, mHeaderC, mHeaderE); + + // WHEN computing the list difference. + mWidgetsDiffReporter.process(currentList, newList, COMPARATOR); + + // THEN "B" is removed from position 1. + verify(mAdapter).notifyItemRemoved(/* position= */ 1); + // THEN "D" is removed from position 2. + verify(mAdapter).notifyItemRemoved(/* position= */ 2); + // THEN "C" is inserted at position 1. + verify(mAdapter).notifyItemInserted(/* position= */ 1); + // THEN "E" is inserted at position 2. + verify(mAdapter).notifyItemInserted(/* position= */ 2); + // THEN the current list contains all elements from the new list. + assertThat(currentList).containsExactlyElementsIn(newList); + } + + @Test + public void headersContentsMix_itemAddedAndRemovedInTheNewList_shouldInvokeCorrectCallbacks() { + // GIVEN the current list has app headers [A, B, E content]. + ArrayList currentList = new ArrayList<>( + List.of(mHeaderA, mHeaderB, mContentE)); + // GIVEN the new list has app headers [A, C content, D]. + List newList = List.of(mHeaderA, mContentC, mHeaderD); + + // WHEN computing the list difference. + mWidgetsDiffReporter.process(currentList, newList, COMPARATOR); + + // THEN "B" is removed from position 1. + verify(mAdapter).notifyItemRemoved(/* position= */ 1); + // THEN "C content" is inserted at position 1. + verify(mAdapter).notifyItemInserted(/* position= */ 1); + // THEN "D" is inserted at position 2. + verify(mAdapter).notifyItemInserted(/* position= */ 2); + // THEN "E content" is removed from position 3. + verify(mAdapter).notifyItemRemoved(/* position= */ 3); + // THEN the current list contains all elements from the new list. + assertThat(currentList).containsExactlyElementsIn(newList); + } + + @Test + public void headersContentsMix_userInteractWithHeader_shouldInvokeCorrectCallbacks() { + // GIVEN the current list has app headers [A, B, E content]. + ArrayList currentList = new ArrayList<>( + List.of(mHeaderA, mHeaderB, mContentE)); + // GIVEN the new list has app headers [A, B, E content]. + List newList = List.of(mHeaderA, mHeaderB, mContentE); + // GIVEN the user has interacted with B. + mHeaderB.setIsWidgetListShown(true); + + // WHEN computing the list difference. + mWidgetsDiffReporter.process(currentList, newList, COMPARATOR); + + // THEN notify "B" has been changed. + verify(mAdapter).notifyItemChanged(/* position= */ 1); + // THEN the current list contains all elements from the new list. + assertThat(currentList).containsExactlyElementsIn(newList); + } + + + private WidgetsListHeaderEntry createWidgetsHeaderEntry(String packageName, String appName, + int numOfWidgets) { + List widgetItems = generateWidgetItems(packageName, numOfWidgets); + PackageItemInfo pInfo = createPackageItemInfo(packageName, appName, + widgetItems.get(0).user); + + return new WidgetsListHeaderEntry(pInfo, /* titleSectionName= */ "", widgetItems); + } + + private WidgetsListContentEntry createWidgetsContentEntry(String packageName, String appName, + int numOfWidgets) { + List widgetItems = generateWidgetItems(packageName, numOfWidgets); + PackageItemInfo pInfo = createPackageItemInfo(packageName, appName, + widgetItems.get(0).user); + + return new WidgetsListContentEntry(pInfo, /* titleSectionName= */ "", widgetItems); + } + + private PackageItemInfo createPackageItemInfo(String packageName, String appName, + UserHandle userHandle) { + PackageItemInfo pInfo = new PackageItemInfo(packageName); + pInfo.title = appName; + pInfo.user = userHandle; + pInfo.bitmap = BitmapInfo.of(Bitmap.createBitmap(10, 10, Bitmap.Config.ALPHA_8), 0); + return pInfo; + } + + private List generateWidgetItems(String packageName, int numOfWidgets) { + ShadowPackageManager packageManager = shadowOf(mContext.getPackageManager()); + ArrayList widgetItems = new ArrayList<>(); + for (int i = 0; i < numOfWidgets; i++) { + ComponentName cn = ComponentName.createRelative(packageName, ".SampleWidget" + i); + AppWidgetProviderInfo widgetInfo = new AppWidgetProviderInfo(); + widgetInfo.provider = cn; + ReflectionHelpers.setField(widgetInfo, "providerInfo", + packageManager.addReceiverIfNotPresent(cn)); + + WidgetItem widgetItem = new WidgetItem( + LauncherAppWidgetProviderInfo.fromProviderInfo(mContext, widgetInfo), + mTestProfile, mIconCache); + widgetItems.add(widgetItem); + } + return widgetItems; + } +} diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListAdapterTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListAdapterTest.java index 9bea2fb406..e94b2532b9 100644 --- a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListAdapterTest.java +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListAdapterTest.java @@ -40,11 +40,13 @@ import com.android.launcher3.model.WidgetItem; import com.android.launcher3.model.data.PackageItemInfo; import com.android.launcher3.widget.model.WidgetsListBaseEntry; import com.android.launcher3.widget.model.WidgetsListContentEntry; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; @@ -56,9 +58,7 @@ import java.util.List; @RunWith(RobolectricTestRunner.class) public final class WidgetsListAdapterTest { - - private static final String TEST_PACKAGE_1 = "com.google.test.1"; - private static final String TEST_PACKAGE_2 = "com.google.test.2"; + private static final String TEST_PACKAGE_PLACEHOLDER = "com.google.test"; @Mock private LayoutInflater mMockLayoutInflater; @Mock private WidgetPreviewLoader mMockWidgetCache; @@ -117,37 +117,76 @@ public final class WidgetsListAdapterTest { } @Test - public void setWidgets_sameApp_moreWidgets_shouldNotifyItemChangedWithWidgetItemInfoDiff() { - // GIVEN the adapter was first populated with test package 1 & test package 2. - WidgetsListBaseEntry testPackage1With2WidgetsListEntry = - generateSampleAppWithWidgets(TEST_PACKAGE_1, /* numOfWidgets= */ 2); - WidgetsListBaseEntry testPackage2With2WidgetsListEntry = - generateSampleAppWithWidgets(TEST_PACKAGE_2, /* numOfWidgets= */ 2); - mAdapter.setWidgets( - List.of(testPackage1With2WidgetsListEntry, testPackage2With2WidgetsListEntry)); + public void headerClick_expanded_shouldNotifyItemChange() { + // GIVEN a list of widgets entries: + // [com.google.test0, com.google.test0 content, + // com.google.test1, com.google.test1 content, + // com.google.test2, com.google.test2 content] + // The visible widgets entries: [com.google.test0, com.google.test1, com.google.test2]. + mAdapter.setWidgets(generateSampleMap(3)); - // WHEN the adapter is updated with the same list of apps but test package 2 has 3 widgets + // WHEN com.google.test.1 header is expanded. + mAdapter.onHeaderClicked(/* isExpanded= */ true, TEST_PACKAGE_PLACEHOLDER + 1); + + // THEN the visible entries list becomes: + // [com.google.test0, com.google.test1, com.google.test1 content, com.google.test2] + // com.google.test.1 content is inserted into position 2. + verify(mListener).onItemRangeInserted(eq(2), eq(1)); + } + + @Test + public void setWidgets_expandedApp_moreWidgets_shouldNotifyItemChangedWithWidgetItemInfoDiff() { + // GIVEN the adapter was first populated with com.google.test0 & com.google.test1. Each app + // has one widget. + ArrayList allEntries = generateSampleMap(2); + mAdapter.setWidgets(allEntries); + // GIVEN test com.google.test1 is expanded. + // Visible entries in the adapter are: + // [com.google.test0, com.google.test1, com.google.test1 content] + mAdapter.onHeaderClicked(/* isExpanded= */ true, TEST_PACKAGE_PLACEHOLDER + 1); + Mockito.reset(mListener); + + // WHEN the adapter is updated with the same list of apps but com.google.test1 has 2 widgets // now. - WidgetsListBaseEntry testPackage1With3WidgetsListEntry = - generateSampleAppWithWidgets(TEST_PACKAGE_2, /* numOfWidgets= */ 2); - mAdapter.setWidgets( - List.of(testPackage1With2WidgetsListEntry, testPackage1With3WidgetsListEntry)); + WidgetsListContentEntry testPackage1ContentEntry = + (WidgetsListContentEntry) allEntries.get(3); + WidgetItem widgetItem = testPackage1ContentEntry.mWidgets.get(0); + WidgetsListContentEntry newTestPackage1ContentEntry = new WidgetsListContentEntry( + testPackage1ContentEntry.mPkgItem, + testPackage1ContentEntry.mTitleSectionName, List.of(widgetItem, widgetItem)); + allEntries.set(3, newTestPackage1ContentEntry); + mAdapter.setWidgets(allEntries); - // THEN the onItemRangeChanged is invoked. - verify(mListener).onItemRangeChanged(eq(1), eq(1), isNull()); + // THEN the onItemRangeChanged is invoked for "com.google.test1 content" at index 2. + verify(mListener).onItemRangeChanged(eq(2), eq(1), isNull()); } @Test public void setWidgets_hodgepodge_shouldInvokeExpectedDataObserverCallbacks() { + // GIVEN a widgets entry list: + // Index: 0| 1 | 2| 3 | 4| 5 | 6| 7 | 8| 9 | + // [A, A content, B, B content, C, C content, D, D content, E, E content] List allAppsWithWidgets = generateSampleMap(5); - // GIVEN the current widgets list consist of [A, B, E]. + // GIVEN the current widgets list consist of [A, A content, B, B content, E, E content]. + // GIVEN the visible widgets list consist of [A, B, E] List currentList = List.of( - allAppsWithWidgets.get(0), allAppsWithWidgets.get(1), allAppsWithWidgets.get(4)); + // A & A content + allAppsWithWidgets.get(0), allAppsWithWidgets.get(1), + // B & B content + allAppsWithWidgets.get(2), allAppsWithWidgets.get(3), + // E & E content + allAppsWithWidgets.get(8), allAppsWithWidgets.get(9)); mAdapter.setWidgets(currentList); - // WHEN the widgets list is updated to [A, C, D]. + // WHEN the widgets list is updated to [A, A content, C, C content, D, D content]. + // WHEN the visible widgets list is updated to [A, C, D]. List newList = List.of( - allAppsWithWidgets.get(0), allAppsWithWidgets.get(2), allAppsWithWidgets.get(3)); + // A & A content + allAppsWithWidgets.get(0), allAppsWithWidgets.get(1), + // C & C content + allAppsWithWidgets.get(4), allAppsWithWidgets.get(5), + // D & D content + allAppsWithWidgets.get(6), allAppsWithWidgets.get(7)); mAdapter.setWidgets(newList); // Computation logic | [Intermediate list during computation] @@ -162,15 +201,23 @@ public final class WidgetsListAdapterTest { } /** - * Helper method to generate the sample widget model map that can be used for the tests - * @param num the number of WidgetItem the map should contain + * Generates a list of sample widget entries. + * + *

Each sample app has 1 widget only. An app is represented by 2 entries, + * {@link WidgetsListHeaderEntry} & {@link WidgetsListContentEntry}. Only + * {@link WidgetsListHeaderEntry} is always visible in the {@link WidgetsListAdapter}. + * {@link WidgetsListContentEntry} is only shown upon clicking the corresponding app's + * {@link WidgetsListHeaderEntry}. Only at most one {@link WidgetsListContentEntry} is shown at + * a time. + * + * @param num the number of apps that have widgets. */ private ArrayList generateSampleMap(int num) { ArrayList result = new ArrayList<>(); if (num <= 0) return result; for (int i = 0; i < num; i++) { - String packageName = "com.placeholder.apk" + i; + String packageName = TEST_PACKAGE_PLACEHOLDER + i; List widgetItems = generateWidgetItems(packageName, /* numOfWidgets= */ 1); @@ -179,23 +226,13 @@ public final class WidgetsListAdapterTest { pInfo.user = widgetItems.get(0).user; pInfo.bitmap = BitmapInfo.of(Bitmap.createBitmap(10, 10, Bitmap.Config.ALPHA_8), 0); + result.add(new WidgetsListHeaderEntry(pInfo, /* titleSectionName= */ "", widgetItems)); result.add(new WidgetsListContentEntry(pInfo, /* titleSectionName= */ "", widgetItems)); } return result; } - private WidgetsListBaseEntry generateSampleAppWithWidgets(String packageName, - int numOfWidgets) { - PackageItemInfo appInfo = new PackageItemInfo(packageName); - appInfo.title = appInfo.packageName; - appInfo.bitmap = BitmapInfo.of(Bitmap.createBitmap(10, 10, Bitmap.Config.ALPHA_8), 0); - - return new WidgetsListContentEntry(appInfo, - /* titleSectionName= */ "", - generateWidgetItems(packageName, numOfWidgets)); - } - private List generateWidgetItems(String packageName, int numOfWidgets) { ShadowPackageManager packageManager = shadowOf(mContext.getPackageManager()); ArrayList widgetItems = new ArrayList<>(); diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java new file mode 100644 index 0000000000..ae5b9a50b7 --- /dev/null +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2021 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.launcher3.widget.picker; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.robolectric.Shadows.shadowOf; + +import android.appwidget.AppWidgetProviderInfo; +import android.content.ComponentName; +import android.content.Context; +import android.graphics.Bitmap; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import com.android.launcher3.DeviceProfile; +import com.android.launcher3.InvariantDeviceProfile; +import com.android.launcher3.LauncherAppWidgetProviderInfo; +import com.android.launcher3.R; +import com.android.launcher3.icons.BitmapInfo; +import com.android.launcher3.icons.ComponentWithLabel; +import com.android.launcher3.icons.IconCache; +import com.android.launcher3.model.WidgetItem; +import com.android.launcher3.model.data.PackageItemInfo; +import com.android.launcher3.testing.TestActivity; +import com.android.launcher3.widget.WidgetCell; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; +import com.android.launcher3.widget.picker.WidgetsListHeaderViewHolderBinder.OnHeaderClickListener; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.android.controller.ActivityController; +import org.robolectric.shadows.ShadowPackageManager; +import org.robolectric.util.ReflectionHelpers; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public final class WidgetsListHeaderViewHolderBinderTest { + private static final String TEST_PACKAGE = "com.google.test"; + private static final String APP_NAME = "Test app"; + + private Context mContext; + private WidgetsListHeaderViewHolderBinder mViewHolderBinder; + private InvariantDeviceProfile mTestProfile; + // Replace ActivityController with ActivityScenario, which is the recommended way for activity + // testing. + private ActivityController mActivityController; + private TestActivity mTestActivity; + private FakeOnHeaderClickListener mFakeOnHeaderClickListener = new FakeOnHeaderClickListener(); + + @Mock + private IconCache mIconCache; + @Mock + private DeviceProfile mDeviceProfile; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = RuntimeEnvironment.application; + mTestProfile = new InvariantDeviceProfile(); + mTestProfile.numRows = 5; + mTestProfile.numColumns = 5; + + mActivityController = Robolectric.buildActivity(TestActivity.class); + mTestActivity = mActivityController.setup().get(); + mTestActivity.setDeviceProfile(mDeviceProfile); + + doAnswer(invocation -> { + ComponentWithLabel componentWithLabel = (ComponentWithLabel) invocation.getArgument(0); + return componentWithLabel.getComponent().getShortClassName(); + }).when(mIconCache).getTitleNoCache(any()); + + mViewHolderBinder = new WidgetsListHeaderViewHolderBinder( + LayoutInflater.from(mTestActivity), + mFakeOnHeaderClickListener); + } + + @After + public void tearDown() { + mActivityController.destroy(); + } + + @Test + public void bindViewHolder_appWith3Widgets_shouldShowTheCorrectAppNameAndSubtitle() { + WidgetsListHeaderHolder viewHolder = mViewHolderBinder.newViewHolder( + new FrameLayout(mTestActivity)); + WidgetsListHeader widgetsListHeader = viewHolder.mWidgetsListHeader; + WidgetsListHeaderEntry entry = generateSampleAppHeader( + APP_NAME, + TEST_PACKAGE, + /* numOfWidgets= */ 3); + mViewHolderBinder.bindViewHolder(viewHolder, entry); + + TextView appTitle = widgetsListHeader.findViewById(R.id.app_title); + TextView appSubtitle = widgetsListHeader.findViewById(R.id.app_subtitle); + assertThat(appTitle.getText()).isEqualTo(APP_NAME); + assertThat(appSubtitle.getText()).isEqualTo("3 widgets"); + } + + private WidgetsListHeaderEntry generateSampleAppHeader(String appName, String packageName, + int numOfWidgets) { + PackageItemInfo appInfo = new PackageItemInfo(packageName); + appInfo.title = appName; + appInfo.bitmap = BitmapInfo.of(Bitmap.createBitmap(10, 10, Bitmap.Config.ALPHA_8), 0); + + return new WidgetsListHeaderEntry(appInfo, + /* titleSectionName= */ "", + generateWidgetItems(packageName, numOfWidgets)); + } + + private List generateWidgetItems(String packageName, int numOfWidgets) { + ShadowPackageManager packageManager = shadowOf(mContext.getPackageManager()); + ArrayList widgetItems = new ArrayList<>(); + for (int i = 0; i < numOfWidgets; i++) { + ComponentName cn = ComponentName.createRelative(packageName, ".SampleWidget" + i); + AppWidgetProviderInfo widgetInfo = new AppWidgetProviderInfo(); + widgetInfo.provider = cn; + ReflectionHelpers.setField(widgetInfo, "providerInfo", + packageManager.addReceiverIfNotPresent(cn)); + + widgetItems.add(new WidgetItem( + LauncherAppWidgetProviderInfo.fromProviderInfo(mContext, widgetInfo), + mTestProfile, mIconCache)); + } + return widgetItems; + } + + private void assertWidgetCellWithLabel(View view, String label) { + assertThat(view).isInstanceOf(WidgetCell.class); + TextView widgetLabel = (TextView) view.findViewById(R.id.widget_name); + assertThat(widgetLabel.getText()).isEqualTo(label); + } + + private final class FakeOnHeaderClickListener implements OnHeaderClickListener { + + boolean mShowWidgets = false; + @Nullable String mHeaderClickedPackage = null; + + @Override + public void onHeaderClicked(boolean showWidgets, String packageName) { + mShowWidgets = showWidgets; + mHeaderClickedPackage = packageName; + } + } +} diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListRowViewHolderBinderTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListRowViewHolderBinderTest.java index 4e9e227a52..ec9fde321c 100644 --- a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListRowViewHolderBinderTest.java +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListRowViewHolderBinderTest.java @@ -118,19 +118,6 @@ public final class WidgetsListRowViewHolderBinderTest { mActivityController.destroy(); } - @Test - public void bindViewHolder_appWith3Widgets_shouldMatchAppTitle() { - WidgetsRowViewHolder viewHolder = mViewHolderBinder.newViewHolder( - new FrameLayout(mTestActivity)); - WidgetsListContentEntry entry = generateSampleAppWithWidgets( - APP_NAME, - TEST_PACKAGE, - /* numOfWidgets= */ 3); - mViewHolderBinder.bindViewHolder(viewHolder, entry); - - assertThat(viewHolder.title.getText()).isEqualTo(APP_NAME); - } - @Test public void bindViewHolder_appWith3Widgets_shouldHave3Widgets() { WidgetsRowViewHolder viewHolder = mViewHolderBinder.newViewHolder( diff --git a/src/com/android/launcher3/BubbleTextView.java b/src/com/android/launcher3/BubbleTextView.java index 21297c9f05..cea8cd61b0 100644 --- a/src/com/android/launcher3/BubbleTextView.java +++ b/src/com/android/launcher3/BubbleTextView.java @@ -24,7 +24,6 @@ import static com.android.launcher3.icons.GraphicsUtils.setColorAlphaBound; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; @@ -34,8 +33,6 @@ import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PointF; -import android.graphics.PorterDuff.Mode; -import android.graphics.PorterDuffColorFilter; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; @@ -52,7 +49,6 @@ import android.widget.TextView; import androidx.annotation.Nullable; import androidx.annotation.UiThread; -import androidx.core.graphics.ColorUtils; import com.android.launcher3.Launcher.OnResumeCallback; import com.android.launcher3.accessibility.LauncherAccessibilityDelegate; @@ -798,7 +794,7 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, if (mIcon != null && mIcon instanceof PlaceHolderIconDrawable && iconUpdateAnimationEnabled()) { - animateIconUpdate((PlaceHolderIconDrawable) mIcon, icon); + ((PlaceHolderIconDrawable) mIcon).animateIconUpdate(icon); } mDisableRelayout = false; @@ -950,28 +946,6 @@ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, } } - private static void animateIconUpdate(PlaceHolderIconDrawable oldIcon, Drawable newIcon) { - int placeholderColor = oldIcon.mPaint.getColor(); - int originalAlpha = Color.alpha(placeholderColor); - - ValueAnimator iconUpdateAnimation = ValueAnimator.ofInt(originalAlpha, 0); - iconUpdateAnimation.setDuration(ICON_UPDATE_ANIMATION_DURATION); - iconUpdateAnimation.addUpdateListener(valueAnimator -> { - int newAlpha = (int) valueAnimator.getAnimatedValue(); - int newColor = ColorUtils.setAlphaComponent(placeholderColor, newAlpha); - - newIcon.setColorFilter(new PorterDuffColorFilter(newColor, Mode.SRC_ATOP)); - }); - iconUpdateAnimation.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - newIcon.setColorFilter(null); - } - }); - iconUpdateAnimation.start(); - } - - @Override public void decorate(int color) { mHighlightColor = color; diff --git a/src/com/android/launcher3/graphics/PlaceHolderIconDrawable.java b/src/com/android/launcher3/graphics/PlaceHolderIconDrawable.java index d347e8fdeb..b6d25c4de3 100644 --- a/src/com/android/launcher3/graphics/PlaceHolderIconDrawable.java +++ b/src/com/android/launcher3/graphics/PlaceHolderIconDrawable.java @@ -19,10 +19,19 @@ import static androidx.core.graphics.ColorUtils.compositeColors; import static com.android.launcher3.graphics.IconShape.getShapePath; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; +import android.graphics.Color; import android.graphics.Path; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; import android.graphics.Rect; +import android.graphics.drawable.Drawable; + +import androidx.core.graphics.ColorUtils; import com.android.launcher3.FastBitmapDrawable; import com.android.launcher3.R; @@ -53,4 +62,27 @@ public class PlaceHolderIconDrawable extends FastBitmapDrawable { canvas.drawPath(mProgressPath, mPaint); canvas.restoreToCount(saveCount); } + + /** Updates this placeholder to {@code newIcon} with animation. */ + public void animateIconUpdate(Drawable newIcon) { + int placeholderColor = mPaint.getColor(); + int originalAlpha = Color.alpha(placeholderColor); + + ValueAnimator iconUpdateAnimation = ValueAnimator.ofInt(originalAlpha, 0); + iconUpdateAnimation.setDuration(375); + iconUpdateAnimation.addUpdateListener(valueAnimator -> { + int newAlpha = (int) valueAnimator.getAnimatedValue(); + int newColor = ColorUtils.setAlphaComponent(placeholderColor, newAlpha); + + newIcon.setColorFilter(new PorterDuffColorFilter(newColor, PorterDuff.Mode.SRC_ATOP)); + }); + iconUpdateAnimation.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + newIcon.setColorFilter(null); + } + }); + iconUpdateAnimation.start(); + } + } diff --git a/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java b/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java index 10ea7dbc5d..09517e1cfb 100644 --- a/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java +++ b/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java @@ -16,9 +16,15 @@ package com.android.launcher3.widget.model; +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import androidx.annotation.IntDef; + import com.android.launcher3.model.data.ItemInfo; import com.android.launcher3.model.data.PackageItemInfo; +import java.lang.annotation.Retention; + /** Holder class to store the package information of an entry shown in the widgets list. */ public abstract class WidgetsListBaseEntry { public final PackageItemInfo mPkgItem; @@ -33,4 +39,22 @@ public abstract class WidgetsListBaseEntry { mPkgItem = pkgItem; mTitleSectionName = titleSectionName; } + + /** + * Returns the ranking of this entry in the + * {@link com.android.launcher3.widget.picker.WidgetsListAdapter}. + * + *

Entries with smaller value should be shown first. See + * {@link com.android.launcher3.widget.picker.WidgetsDiffReporter} for more details. + */ + @Rank + public abstract int getRank(); + + @Retention(SOURCE) + @IntDef({RANK_WIDGETS_LIST_HEADER, RANK_WIDGETS_LIST_CONTENT}) + public @interface Rank { + } + + public static final int RANK_WIDGETS_LIST_HEADER = 1; + public static final int RANK_WIDGETS_LIST_CONTENT = 2; } diff --git a/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java b/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java index 407f194cc3..b0cb8c7455 100644 --- a/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java +++ b/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java @@ -41,4 +41,10 @@ public final class WidgetsListContentEntry extends WidgetsListBaseEntry { public String toString() { return mPkgItem.packageName + ":" + mWidgets.size(); } + + @Override + @Rank + public int getRank() { + return RANK_WIDGETS_LIST_CONTENT; + } } diff --git a/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java b/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java new file mode 100644 index 0000000000..6899647764 --- /dev/null +++ b/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2021 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.launcher3.widget.model; + +import com.android.launcher3.model.WidgetItem; +import com.android.launcher3.model.data.PackageItemInfo; + +import java.util.Collection; + +/** An information holder for an app which has widgets or/and shortcuts. */ +public final class WidgetsListHeaderEntry extends WidgetsListBaseEntry { + + public final int widgetsCount; + public final int shortcutsCount; + + private boolean mIsWidgetListShown = false; + private boolean mHasEntryUpdated = false; + + public WidgetsListHeaderEntry(PackageItemInfo pkgItem, String titleSectionName, + Collection items) { + super(pkgItem, titleSectionName); + widgetsCount = (int) items.stream().filter(item -> item.widgetInfo != null).count(); + shortcutsCount = Math.max(0, items.size() - widgetsCount); + } + + /** Sets if the widgets list associated with this header is shown. */ + public void setIsWidgetListShown(boolean isWidgetListShown) { + if (mIsWidgetListShown != isWidgetListShown) { + this.mIsWidgetListShown = isWidgetListShown; + mHasEntryUpdated = true; + } else { + mHasEntryUpdated = false; + } + } + + /** Returns {@code true} if the widgets list associated with this header is shown. */ + public boolean isWidgetListShown() { + return mIsWidgetListShown; + } + + /** Returns {@code true} if this entry has been updated due to user interactions. */ + public boolean hasEntryUpdated() { + return mHasEntryUpdated; + } + + @Override + @Rank + public int getRank() { + return RANK_WIDGETS_LIST_HEADER; + } +} diff --git a/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java b/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java index 398d9ba448..dbd1bdf523 100644 --- a/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java +++ b/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java @@ -24,10 +24,12 @@ import com.android.launcher3.icons.IconCache; import com.android.launcher3.model.data.PackageItemInfo; import com.android.launcher3.widget.model.WidgetsListBaseEntry; import com.android.launcher3.widget.model.WidgetsListContentEntry; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; import com.android.launcher3.widget.picker.WidgetsListAdapter.WidgetListBaseRowEntryComparator; import java.util.ArrayList; import java.util.Iterator; +import java.util.List; /** * Do diff on widget's tray list items and call the {@link RecyclerView.Adapter} @@ -50,7 +52,7 @@ public class WidgetsDiffReporter { * relevant {@link androidx.recyclerview.widget.RecyclerView.RecyclerViewDataObserver} methods. */ public void process(ArrayList currentEntries, - ArrayList newEntries, + List newEntries, WidgetListBaseRowEntryComparator comparator) { if (DEBUG) { Log.d(TAG, "process oldEntries#=" + currentEntries.size() @@ -78,7 +80,7 @@ public class WidgetsDiffReporter { WidgetsListBaseEntry newRowEntry = newIter.next(); do { - int diff = comparePackageName(orgRowEntry, newRowEntry, comparator); + int diff = compareAppNameAndType(orgRowEntry, newRowEntry, comparator); if (DEBUG) { Log.d(TAG, String.format("diff=%d orgRowEntry (%s) newRowEntry (%s)", diff, orgRowEntry != null ? orgRowEntry.toString() : null, @@ -106,11 +108,13 @@ public class WidgetsDiffReporter { mListener.notifyItemInserted(index); } else { - // same package name but, + // same app name & type but, // did the icon, title, etc, change? + // or did the header view changed due to user interactions? // or did the widget size and desc, span, etc change? if (!isSamePackageItemInfo(orgRowEntry.mPkgItem, newRowEntry.mPkgItem) - || !areWidgetsEqual(orgRowEntry, newRowEntry)) { + || hasHeaderUpdated(newRowEntry) + || hasWidgetsListChanged(orgRowEntry, newRowEntry)) { index = currentEntries.indexOf(orgRowEntry); currentEntries.set(index, newRowEntry); mListener.notifyItemChanged(index); @@ -126,10 +130,13 @@ public class WidgetsDiffReporter { } /** - * Compare package name using the same comparator as in {@link WidgetsListAdapter}. - * Also handle null row pointers. + * Compares the app name and then entry type for the given {@link WidgetsListBaseEntry}s. + * + * @Return 0 if both entries' order is the same. Negative integer if {@code newRowEntry} should + * order before {@code orgRowEntry}. Positive integer if {@code orgRowEntry} should + * order before {@code newRowEntry}. */ - private int comparePackageName(WidgetsListBaseEntry curRow, WidgetsListBaseEntry newRow, + private int compareAppNameAndType(WidgetsListBaseEntry curRow, WidgetsListBaseEntry newRow, WidgetListBaseRowEntryComparator comparator) { if (curRow == null && newRow == null) { throw new IllegalStateException( @@ -141,10 +148,18 @@ public class WidgetsDiffReporter { } else if (curRow != null && newRow == null) { return -1; // old row needs to be deleted } - return comparator.compare(curRow, newRow); + int diff = comparator.compare(curRow, newRow); + if (diff == 0) { + return newRow.getRank() - curRow.getRank(); + } + return diff; } - private boolean areWidgetsEqual(WidgetsListBaseEntry curRow, + /** + * Returns {@code true} if both {@code curRow} & {@code newRow} are + * {@link WidgetsListContentEntry}s with a different list of widgets. + */ + private boolean hasWidgetsListChanged(WidgetsListBaseEntry curRow, WidgetsListBaseEntry newRow) { if (!(curRow instanceof WidgetsListContentEntry) || !(newRow instanceof WidgetsListContentEntry)) { @@ -152,7 +167,19 @@ public class WidgetsDiffReporter { } WidgetsListContentEntry orgRowEntry = (WidgetsListContentEntry) curRow; WidgetsListContentEntry newRowEntry = (WidgetsListContentEntry) newRow; - return orgRowEntry.mWidgets.equals(newRowEntry.mWidgets); + return !orgRowEntry.mWidgets.equals(newRowEntry.mWidgets); + } + + /** + * Returns {@code true} if {@code newRow} is {@link WidgetsListHeaderEntry} and its content has + * been changed due to user interactions. + */ + private boolean hasHeaderUpdated(WidgetsListBaseEntry newRow) { + if (!(newRow instanceof WidgetsListHeaderEntry)) { + return false; + } + WidgetsListHeaderEntry newRowEntry = (WidgetsListHeaderEntry) newRow; + return newRowEntry.hasEntryUpdated(); } private boolean isSamePackageItemInfo(PackageItemInfo curInfo, PackageItemInfo newInfo) { diff --git a/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java b/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java index 9d308423e6..5ec7f3b063 100644 --- a/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java +++ b/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java @@ -24,6 +24,7 @@ import android.view.View.OnClickListener; import android.view.View.OnLongClickListener; import android.view.ViewGroup; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.Adapter; import androidx.recyclerview.widget.RecyclerView.ViewHolder; @@ -36,32 +37,42 @@ import com.android.launcher3.util.LabelComparator; import com.android.launcher3.widget.WidgetCell; import com.android.launcher3.widget.model.WidgetsListBaseEntry; import com.android.launcher3.widget.model.WidgetsListContentEntry; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; +import com.android.launcher3.widget.picker.WidgetsListHeaderViewHolderBinder.OnHeaderClickListener; import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.stream.Collectors; /** - * List view adapter for the widget tray. + * Recycler view adapter for the widget tray. * - *

Memory vs. Performance: - * The less number of types of views are inserted into a {@link RecyclerView}, the more recycling - * happens and less memory is consumed. + *

This adapter supports view binding of subclasses of {@link WidgetsListBaseEntry}. There are 2 + * subclasses: {@link WidgetsListHeader} & {@link WidgetsListContentEntry}. + * {@link WidgetsListHeader} entries are always visible in the recycler view. At most one + * {@link WidgetsListContentEntry} is shown in the recycler view at any time. Clicking a + * {@link WidgetsListHeader} will result in expanding / collapsing a corresponding + * {@link WidgetsListContentEntry} of the same app. */ -public class WidgetsListAdapter extends Adapter { +public class WidgetsListAdapter extends Adapter implements OnHeaderClickListener { private static final String TAG = "WidgetsListAdapter"; private static final boolean DEBUG = false; /** Uniquely identifies widgets list view type within the app. */ private static final int VIEW_TYPE_WIDGETS_LIST = R.layout.widgets_list_row_view; + private static final int VIEW_TYPE_WIDGETS_HEADER = R.layout.widgets_list_row_header; private final WidgetsDiffReporter mDiffReporter; private final SparseArray mViewHolderBinders = new SparseArray<>(); private final WidgetsListRowViewHolderBinder mWidgetsListRowViewHolderBinder; + private final WidgetListBaseRowEntryComparator mRowComparator = + new WidgetListBaseRowEntryComparator(); - private ArrayList mEntries = new ArrayList<>(); + private List mAllEntries = new ArrayList<>(); + private ArrayList mVisibleEntries = new ArrayList<>(); + @Nullable private String mWidgetsContentVisiblePackage = null; public WidgetsListAdapter(Context context, LayoutInflater layoutInflater, WidgetPreviewLoader widgetPreviewLoader, IconCache iconCache, @@ -70,6 +81,8 @@ public class WidgetsListAdapter extends Adapter { mWidgetsListRowViewHolderBinder = new WidgetsListRowViewHolderBinder(context, layoutInflater, iconClickListener, iconLongClickListener, widgetPreviewLoader); mViewHolderBinders.put(VIEW_TYPE_WIDGETS_LIST, mWidgetsListRowViewHolderBinder); + mViewHolderBinders.put(VIEW_TYPE_WIDGETS_HEADER, + new WidgetsListHeaderViewHolderBinder(layoutInflater, this::onHeaderClicked)); } /** @@ -96,26 +109,39 @@ public class WidgetsListAdapter extends Adapter { @Override public int getItemCount() { - return mEntries.size(); + return mVisibleEntries.size(); } /** Gets the section name for {@link com.android.launcher3.views.RecyclerViewFastScroller}. */ public String getSectionName(int pos) { - return mEntries.get(pos).mTitleSectionName; + return mVisibleEntries.get(pos).mTitleSectionName; } /** Updates the widget list. */ public void setWidgets(List tempEntries) { - ArrayList newEntries = new ArrayList<>(tempEntries); - WidgetListBaseRowEntryComparator rowComparator = new WidgetListBaseRowEntryComparator(); - Collections.sort(newEntries, rowComparator); - mDiffReporter.process(mEntries, newEntries, rowComparator); + mAllEntries = tempEntries.stream().sorted(mRowComparator) + .collect(Collectors.toList()); + updateVisibleEntries(); + } + + private void updateVisibleEntries() { + mAllEntries.forEach(entry -> { + if (entry instanceof WidgetsListHeaderEntry) { + ((WidgetsListHeaderEntry) entry).setIsWidgetListShown( + entry.mPkgItem.packageName.equals(mWidgetsContentVisiblePackage)); + } + }); + List newVisibleEntries = mAllEntries.stream() + .filter(entry -> entry instanceof WidgetsListHeaderEntry + || entry.mPkgItem.packageName.equals(mWidgetsContentVisiblePackage)) + .collect(Collectors.toList()); + mDiffReporter.process(mVisibleEntries, newVisibleEntries, mRowComparator); } @Override public void onBindViewHolder(ViewHolder holder, int pos) { ViewHolderBinder viewHolderBinder = mViewHolderBinders.get(getItemViewType(pos)); - viewHolderBinder.bindViewHolder(holder, mEntries.get(pos)); + viewHolderBinder.bindViewHolder(holder, mVisibleEntries.get(pos)); } @Override @@ -148,13 +174,26 @@ public class WidgetsListAdapter extends Adapter { @Override public int getItemViewType(int pos) { - WidgetsListBaseEntry entry = mEntries.get(pos); + WidgetsListBaseEntry entry = mVisibleEntries.get(pos); if (entry instanceof WidgetsListContentEntry) { return VIEW_TYPE_WIDGETS_LIST; + } else if (entry instanceof WidgetsListHeaderEntry) { + return VIEW_TYPE_WIDGETS_HEADER; } throw new UnsupportedOperationException("ViewHolderBinder not found for " + entry); } + @Override + public void onHeaderClicked(boolean showWidgets, String expandedPackage) { + if (showWidgets) { + mWidgetsContentVisiblePackage = expandedPackage; + updateVisibleEntries(); + } else if (expandedPackage.equals(mWidgetsContentVisiblePackage)) { + mWidgetsContentVisiblePackage = null; + updateVisibleEntries(); + } + } + /** Comparator for sorting WidgetListRowEntry based on package title. */ public static class WidgetListBaseRowEntryComparator implements Comparator { diff --git a/src/com/android/launcher3/widget/picker/WidgetsListHeader.java b/src/com/android/launcher3/widget/picker/WidgetsListHeader.java new file mode 100644 index 0000000000..823fb7bcd2 --- /dev/null +++ b/src/com/android/launcher3/widget/picker/WidgetsListHeader.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2021 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.launcher3.widget.picker; + +import static com.android.launcher3.FastBitmapDrawable.newIcon; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +import com.android.launcher3.DeviceProfile; +import com.android.launcher3.FastBitmapDrawable; +import com.android.launcher3.LauncherAppState; +import com.android.launcher3.R; +import com.android.launcher3.graphics.PlaceHolderIconDrawable; +import com.android.launcher3.icons.IconCache.ItemInfoUpdateReceiver; +import com.android.launcher3.icons.cache.HandlerRunnable; +import com.android.launcher3.model.data.ItemInfoWithIcon; +import com.android.launcher3.model.data.PackageItemInfo; +import com.android.launcher3.views.ActivityContext; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; + +/** + * A UI represents a header of an app shown in the full widgets tray. + * + * It is a {@link LinearLayout} which contains an app icon, an app name, a subtitle and a checkbox + * which indicates if the widgets content view underneath this header should be shown. + */ +public final class WidgetsListHeader extends LinearLayout implements ItemInfoUpdateReceiver { + + private boolean mEnableIconUpdateAnimation = false; + + @Nullable private HandlerRunnable mIconLoadRequest; + @Nullable private Drawable mIconDrawable; + private final int mIconSize; + + private ImageView mAppIcon; + private TextView mTitle; + private TextView mSubtitle; + + private CheckBox mExpandToggle; + private boolean mIsExpanded = false; + + public WidgetsListHeader(Context context) { + this(context, /* attrs= */ null); + } + + public WidgetsListHeader(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, /* defStyle= */ 0); + } + + public WidgetsListHeader(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + ActivityContext activity = ActivityContext.lookupContext(context); + DeviceProfile grid = activity.getDeviceProfile(); + TypedArray a = context.obtainStyledAttributes(attrs, + R.styleable.WidgetsListRowHeader, defStyleAttr, /* defStyleRes= */ 0); + mIconSize = a.getDimensionPixelSize(R.styleable.WidgetsListRowHeader_appIconSize, + grid.iconSizePx); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mAppIcon = findViewById(R.id.app_icon); + mTitle = findViewById(R.id.app_title); + mSubtitle = findViewById(R.id.app_subtitle); + mExpandToggle = findViewById(R.id.toggle); + } + + /** + * Sets a {@link OnExpansionChangeListener} to get a callback when this app widgets section + * expands / collapses. + */ + @UiThread + public void setOnExpandChangeListener( + @Nullable OnExpansionChangeListener onExpandChangeListener) { + // Use the entire touch area of this view to expand / collapse an app widgets section. + setOnClickListener(view -> { + setExpanded(!mIsExpanded); + onExpandChangeListener.onExpansionChange(mIsExpanded); + }); + } + + /** Sets the expand toggle to expand / collapse. */ + @UiThread + public void setExpanded(boolean isExpanded) { + this.mIsExpanded = isExpanded; + mExpandToggle.setChecked(isExpanded); + } + + /** Apply app icon, labels and tag using a generic {@link WidgetsListHeaderEntry}. */ + @UiThread + public void applyFromItemInfoWithIcon(WidgetsListHeaderEntry entry) { + applyIconAndLabel(entry); + } + + @UiThread + private void applyIconAndLabel(WidgetsListHeaderEntry entry) { + PackageItemInfo info = entry.mPkgItem; + setIcon(info); + setTitles(entry); + setExpanded(entry.isWidgetListShown()); + + super.setTag(info); + + verifyHighRes(); + } + + private void setIcon(PackageItemInfo info) { + FastBitmapDrawable icon = newIcon(getContext(), info); + applyDrawables(icon); + mIconDrawable = icon; + if (mIconDrawable != null) { + mIconDrawable.setVisible( + /* visible= */ getWindowVisibility() == VISIBLE && isShown(), + /* restart= */ false); + } + } + + private void applyDrawables(Drawable icon) { + icon.setBounds(0, 0, mIconSize, mIconSize); + + mAppIcon.setImageDrawable(icon); + + // If the current icon is a placeholder color, animate its update. + if (mIconDrawable != null + && mIconDrawable instanceof PlaceHolderIconDrawable + && mEnableIconUpdateAnimation) { + ((PlaceHolderIconDrawable) mIconDrawable).animateIconUpdate(icon); + } + } + + private void setTitles(WidgetsListHeaderEntry entry) { + mTitle.setText(entry.mPkgItem.title); + + if (entry.widgetsCount > 0) { + Resources resources = getContext().getResources(); + mSubtitle.setText(resources.getQuantityString(R.plurals.widgets_tray_subtitle, + entry.widgetsCount, entry.widgetsCount)); + mSubtitle.setVisibility(VISIBLE); + } else { + mSubtitle.setVisibility(GONE); + } + } + + @Override + public void reapplyItemInfo(ItemInfoWithIcon info) { + if (getTag() == info) { + mIconLoadRequest = null; + mEnableIconUpdateAnimation = true; + + // Optimization: Starting in N, pre-uploads the bitmap to RenderThread. + info.bitmap.icon.prepareToDraw(); + + setIcon((PackageItemInfo) info); + + mEnableIconUpdateAnimation = false; + } + } + + /** Verifies that the current icon is high-res otherwise posts a request to load the icon. */ + public void verifyHighRes() { + if (mIconLoadRequest != null) { + mIconLoadRequest.cancel(); + mIconLoadRequest = null; + } + if (getTag() instanceof ItemInfoWithIcon) { + ItemInfoWithIcon info = (ItemInfoWithIcon) getTag(); + if (info.usingLowResIcon()) { + mIconLoadRequest = LauncherAppState.getInstance(getContext()).getIconCache() + .updateIconInBackground(this, info); + } + } + } + + /** A listener for the widget section expansion / collapse events. */ + public interface OnExpansionChangeListener { + /** Notifies that the widget section is expanded or collapsed. */ + void onExpansionChange(boolean isExpanded); + } +} diff --git a/src/com/android/launcher3/widget/picker/WidgetsListHeaderHolder.java b/src/com/android/launcher3/widget/picker/WidgetsListHeaderHolder.java new file mode 100644 index 0000000000..d4e1b1c4a5 --- /dev/null +++ b/src/com/android/launcher3/widget/picker/WidgetsListHeaderHolder.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2021 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.launcher3.widget.picker; + +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +/** + * A {@link ViewHolder} for {@link WidgetsListHeader} of an app, which renders the app icon, the app + * name, label and a button for showing / hiding widgets. + */ +public final class WidgetsListHeaderHolder extends ViewHolder { + final WidgetsListHeader mWidgetsListHeader; + + public WidgetsListHeaderHolder(WidgetsListHeader view) { + super(view); + + mWidgetsListHeader = view; + } +} diff --git a/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinder.java b/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinder.java new file mode 100644 index 0000000000..ed53e6fbc9 --- /dev/null +++ b/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinder.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2021 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.launcher3.widget.picker; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.android.launcher3.R; +import com.android.launcher3.recyclerview.ViewHolderBinder; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; + +/** + * Binds data from {@link WidgetsListHeaderEntry} to UI elements in {@link WidgetsListHeaderHolder}. + */ +public final class WidgetsListHeaderViewHolderBinder implements + ViewHolderBinder { + private final LayoutInflater mLayoutInflater; + private final OnHeaderClickListener mOnHeaderClickListener; + + public WidgetsListHeaderViewHolderBinder(LayoutInflater layoutInflater, + OnHeaderClickListener onHeaderClickListener) { + mLayoutInflater = layoutInflater; + mOnHeaderClickListener = onHeaderClickListener; + } + + @Override + public WidgetsListHeaderHolder newViewHolder(ViewGroup parent) { + WidgetsListHeader header = (WidgetsListHeader) mLayoutInflater.inflate( + R.layout.widgets_list_row_header, parent, false); + + return new WidgetsListHeaderHolder(header); + } + + @Override + public void bindViewHolder(WidgetsListHeaderHolder viewHolder, WidgetsListHeaderEntry data) { + WidgetsListHeader widgetsListHeader = viewHolder.mWidgetsListHeader; + widgetsListHeader.applyFromItemInfoWithIcon(data); + widgetsListHeader.setExpanded(data.isWidgetListShown()); + widgetsListHeader.setOnExpandChangeListener(isExpanded -> + mOnHeaderClickListener.onHeaderClicked(isExpanded, data.mPkgItem.packageName)); + } + + /** A listener to be invoked when {@link WidgetsListHeader} is clicked. */ + public interface OnHeaderClickListener { + /** Calls when {@link WidgetsListHeader} is clicked to show / hide widgets for a package. */ + void onHeaderClicked(boolean showWidgets, String packageName); + } +} diff --git a/src/com/android/launcher3/widget/picker/WidgetsListRowViewHolderBinder.java b/src/com/android/launcher3/widget/picker/WidgetsListRowViewHolderBinder.java index 22a8d0071c..cec6b807ec 100644 --- a/src/com/android/launcher3/widget/picker/WidgetsListRowViewHolderBinder.java +++ b/src/com/android/launcher3/widget/picker/WidgetsListRowViewHolderBinder.java @@ -76,7 +76,7 @@ public class WidgetsListRowViewHolderBinder } ViewGroup container = (ViewGroup) mLayoutInflater.inflate( - R.layout.widgets_list_row_view, parent, false); + R.layout.widgets_scroll_container, parent, false); // if the end padding is 0, then container view (horizontal scroll view) doesn't respect // the end of the linear layout width + the start padding and doesn't allow scrolling. @@ -122,9 +122,6 @@ public class WidgetsListRowViewHolderBinder } } - // Bind the views in the application info section. - holder.title.applyFromItemInfoWithIcon(entry.mPkgItem); - // Bind the view in the widget horizontal tray region. for (int i = 0; i < infoList.size(); i++) { WidgetCell widget = (WidgetCell) row.getChildAt(2 * i); diff --git a/src/com/android/launcher3/widget/picker/WidgetsRowViewHolder.java b/src/com/android/launcher3/widget/picker/WidgetsRowViewHolder.java index 9be079e826..ae945846e1 100644 --- a/src/com/android/launcher3/widget/picker/WidgetsRowViewHolder.java +++ b/src/com/android/launcher3/widget/picker/WidgetsRowViewHolder.java @@ -19,20 +19,16 @@ import android.view.ViewGroup; import androidx.recyclerview.widget.RecyclerView.ViewHolder; -import com.android.launcher3.BubbleTextView; import com.android.launcher3.R; -/** A {@link ViewHolder} for a row in the full widget picker. */ +/** A {@link ViewHolder} for showing widgets of an app in the full widget picker. */ public final class WidgetsRowViewHolder extends ViewHolder { public final ViewGroup cellContainer; - public final BubbleTextView title; public WidgetsRowViewHolder(ViewGroup v) { super(v); cellContainer = v.findViewById(R.id.widgets_cell_list); - title = v.findViewById(R.id.section); - title.setAccessibilityDelegate(null); } } diff --git a/src_shortcuts_overrides/com/android/launcher3/model/WidgetsModel.java b/src_shortcuts_overrides/com/android/launcher3/model/WidgetsModel.java index f27922b757..30c9b5ffd8 100644 --- a/src_shortcuts_overrides/com/android/launcher3/model/WidgetsModel.java +++ b/src_shortcuts_overrides/com/android/launcher3/model/WidgetsModel.java @@ -31,6 +31,7 @@ import com.android.launcher3.util.Preconditions; import com.android.launcher3.widget.WidgetManagerHelper; import com.android.launcher3.widget.model.WidgetsListBaseEntry; import com.android.launcher3.widget.model.WidgetsListContentEntry; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; import com.android.launcher3.widget.picker.WidgetsDiffReporter; import java.util.ArrayList; @@ -73,11 +74,11 @@ public class WidgetsModel { for (Map.Entry> entry : mWidgetsList.entrySet()) { PackageItemInfo pkgItem = entry.getKey(); + List widgetItems = entry.getValue(); String sectionName = (pkgItem.title == null) ? "" : indexer.computeSectionName(pkgItem.title); - WidgetsListContentEntry row = - new WidgetsListContentEntry(pkgItem, sectionName, entry.getValue()); - result.add(row); + result.add(new WidgetsListHeaderEntry(pkgItem, sectionName, widgetItems)); + result.add(new WidgetsListContentEntry(pkgItem, sectionName, widgetItems)); } return result; } diff --git a/tests/src/com/android/launcher3/ui/widget/AddConfigWidgetTest.java b/tests/src/com/android/launcher3/ui/widget/AddConfigWidgetTest.java index 9d4ccff974..737f891d78 100644 --- a/tests/src/com/android/launcher3/ui/widget/AddConfigWidgetTest.java +++ b/tests/src/com/android/launcher3/ui/widget/AddConfigWidgetTest.java @@ -92,9 +92,8 @@ public class AddConfigWidgetTest extends AbstractLauncherUiTest { // Drag widget to homescreen WidgetConfigStartupMonitor monitor = new WidgetConfigStartupMonitor(); - widgets. - getWidget(mWidgetInfo.getLabel(mTargetContext.getPackageManager())). - dragToWorkspace(true, false); + widgets.getWidget(mWidgetInfo.getLabel(mTargetContext.getPackageManager())) + .dragToWorkspace(true, false); // Widget id for which the config activity was opened mWidgetId = monitor.getWidgetId(); diff --git a/tests/tapl/com/android/launcher3/tapl/Widgets.java b/tests/tapl/com/android/launcher3/tapl/Widgets.java index 49af616b85..f95abdb4b2 100644 --- a/tests/tapl/com/android/launcher3/tapl/Widgets.java +++ b/tests/tapl/com/android/launcher3/tapl/Widgets.java @@ -31,6 +31,7 @@ import com.android.launcher3.tapl.LauncherInstrumentation.GestureScope; import com.android.launcher3.testing.TestProtocol; import java.util.Collection; +import java.util.List; /** * All widgets container. @@ -101,22 +102,28 @@ public final class Widgets extends LauncherInstrumentation.VisibleContainer { try (LauncherInstrumentation.Closable e = mLauncher.eventsCheck(); LauncherInstrumentation.Closable c = mLauncher.addContextLayer( "getting widget " + labelText + " in widgets list")) { - final UiObject2 widgetsContainer = verifyActiveContainer(); + final UiObject2 fullWidgetsPicker = verifyActiveContainer(); mLauncher.assertTrue("Widgets container didn't become scrollable", - widgetsContainer.wait(Until.scrollable(true), WAIT_TIME_MS)); + fullWidgetsPicker.wait(Until.scrollable(true), WAIT_TIME_MS)); final Point displaySize = mLauncher.getRealDisplaySize(); - final BySelector labelSelector = By.clazz("android.widget.TextView").text(labelText); + final UiObject2 widgetsContainer = findTestAppWidgetsScrollContainer(); + mLauncher.assertTrue("Can't locate widgets list for the test app: " + + mLauncher.getLauncherPackageName(), + widgetsContainer != null); + final BySelector labelSelector = By.clazz("android.widget.TextView").text(labelText); int i = 0; for (; ; ) { - final Collection cells = mLauncher.getObjectsInContainer( - widgetsContainer, "widgets_scroll_container"); - mLauncher.assertTrue("Widgets doesn't have 2 rows", cells.size() >= 2); + final Collection cells = widgetsContainer.getChildren(); + mLauncher.assertTrue("Widgets doesn't have 2 rows: ", cells.size() >= 2); for (UiObject2 cell : cells) { final UiObject2 label = cell.findObject(labelSelector); + // The logic below doesn't handle the case which a widget cell of the given + // label is not yet visible on the horizontal scrolling container. This won't be + // an issue once we get rid of the horizontal scrolling container. if (label == null) continue; - final UiObject2 widget = label.getParent().getParent(); + final UiObject2 widget = cell; mLauncher.assertEquals( "View is not WidgetCell", "com.android.launcher3.widget.WidgetCell", @@ -131,7 +138,7 @@ public final class Widgets extends LauncherInstrumentation.VisibleContainer { <= displaySize.y - mLauncher.getBottomGestureSize()) { int visibleDelta = maxWidth - mLauncher.getVisibleBounds(widget).width(); if (visibleDelta > 0) { - Rect parentBounds = mLauncher.getVisibleBounds(cell); + Rect parentBounds = mLauncher.getVisibleBounds(cell.getParent()); mLauncher.linearGesture(parentBounds.centerX() + visibleDelta + mLauncher.getTouchSlop(), parentBounds.centerY(), parentBounds.centerX(), @@ -153,4 +160,53 @@ public final class Widgets extends LauncherInstrumentation.VisibleContainer { } } } + + /** Finds the widgets list of this test app from the collapsed full widgets picker. */ + private UiObject2 findTestAppWidgetsScrollContainer() { + final BySelector headerSelector = By.res(mLauncher.getLauncherPackageName(), + "widgets_list_header"); + final BySelector targetAppSelector = By.clazz("android.widget.TextView").text( + mLauncher.getContext().getPackageName()); + final BySelector widgetsContainerSelector = By.res(mLauncher.getLauncherPackageName(), + "widgets_cell_list"); + + boolean hasHeaderExpanded = false; + for (int i = 0; i < 40; i++) { + UiObject2 fullWidgetsPicker = verifyActiveContainer(); + + UiObject2 header = fullWidgetsPicker.findObject(headerSelector); + mLauncher.assertTrue("Can't find a widget header", header != null); + + // Look for a header that has the test app name. + UiObject2 headerTitle = fullWidgetsPicker.findObject(targetAppSelector); + if (headerTitle != null) { + // If we find the header and it has not been expanded, let's click it to see the + // widgets list. + if (!hasHeaderExpanded) { + hasHeaderExpanded = true; + mLauncher.clickLauncherObject(headerTitle); + // After clicking the header, the recyclerview has been updated. Let's refresh + // the container UIObject2. + fullWidgetsPicker = verifyActiveContainer(); + // Refresh headerTitle because the first instance is stale after + // verifyActiveContainer call. + headerTitle = fullWidgetsPicker.findObject(targetAppSelector); + } + + // Look for a widgets list. + UiObject2 widgetsContainer = fullWidgetsPicker.findObject(widgetsContainerSelector); + if (widgetsContainer != null) { + // Make sure the widgets list is fully visible on the screen. + mLauncher.scrollToLastVisibleRow(fullWidgetsPicker, + widgetsContainer.getChildren(), 0); + return widgetsContainer; + } + mLauncher.scrollToLastVisibleRow(fullWidgetsPicker, List.of(headerTitle), 0); + } else { + mLauncher.scrollToLastVisibleRow(fullWidgetsPicker, header.getChildren(), 0); + } + } + + return null; + } }