diff --git a/res/drawable/ic_gm_close_24.xml b/res/drawable/ic_gm_close_24.xml new file mode 100644 index 0000000000..2c9c932fa3 --- /dev/null +++ b/res/drawable/ic_gm_close_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/res/layout/widgets_full_sheet.xml b/res/layout/widgets_full_sheet.xml index 6c18d7ac75..226c4f713a 100644 --- a/res/layout/widgets_full_sheet.xml +++ b/res/layout/widgets_full_sheet.xml @@ -51,5 +51,13 @@ android:layout_alignParentEnd="true" android:layout_alignParentTop="true" android:layout_marginEnd="@dimen/fastscroll_end_margin" /> + + + \ No newline at end of file diff --git a/res/layout/widgets_full_sheet_search_and_recommendations.xml b/res/layout/widgets_full_sheet_search_and_recommendations.xml index 9a6f922d7d..6182255dc6 100644 --- a/res/layout/widgets_full_sheet_search_and_recommendations.xml +++ b/res/layout/widgets_full_sheet_search_and_recommendations.xml @@ -34,16 +34,5 @@ android:textSize="24sp" android:layout_marginTop="16dp" android:text="@string/widget_button_text"/> - - + diff --git a/res/layout/widgets_list_row_header.xml b/res/layout/widgets_list_row_header.xml index 77b5a9a9b1..15902867ad 100644 --- a/res/layout/widgets_list_row_header.xml +++ b/res/layout/widgets_list_row_header.xml @@ -52,6 +52,8 @@ android:id="@+id/app_subtitle" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:ellipsize="end" + android:maxLines="1" tools:text="m widgets, n shortcuts" /> diff --git a/res/layout/widgets_search_bar.xml b/res/layout/widgets_search_bar.xml new file mode 100644 index 0000000000..252637d9cf --- /dev/null +++ b/res/layout/widgets_search_bar.xml @@ -0,0 +1,33 @@ + + + + + + + \ No newline at end of file diff --git a/res/values/id.xml b/res/values/id.xml new file mode 100644 index 0000000000..39c49bd5e1 --- /dev/null +++ b/res/values/id.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java index b972c6ff0e..cc36f630c9 100644 --- a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java @@ -221,6 +221,27 @@ public final class WidgetsDiffReporterTest { assertThat(currentList).containsExactlyElementsIn(newList); } + @Test + public void headersContentsMix_headerWidgetsModified_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 one of the headers widgets list modified. + List newList = List.of( + new WidgetsListHeaderEntry( + mHeaderA.mPkgItem, mHeaderA.mTitleSectionName, + mHeaderA.mWidgets.subList(0, 1)), + mHeaderB, mContentE); + + // WHEN computing the list difference. + mWidgetsDiffReporter.process(currentList, newList, COMPARATOR); + + // THEN notify "A" has been changed. + verify(mAdapter).notifyItemChanged(/* position= */ 0); + // THEN the current list contains all elements from the new list. + assertThat(currentList).containsExactlyElementsIn(newList); + } + private WidgetsListHeaderEntry createWidgetsHeaderEntry(String packageName, String appName, int numOfWidgets) { 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 a7c8d92f79..e1214ff39f 100644 --- a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListAdapterTest.java +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListAdapterTest.java @@ -26,6 +26,8 @@ import android.appwidget.AppWidgetProviderInfo; import android.content.ComponentName; import android.content.Context; import android.graphics.Bitmap; +import android.os.Process; +import android.os.UserHandle; import android.view.LayoutInflater; import androidx.recyclerview.widget.RecyclerView; @@ -37,6 +39,7 @@ 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.util.PackageUserKey; import com.android.launcher3.widget.LauncherAppWidgetProviderInfo; import com.android.launcher3.widget.model.WidgetsListBaseEntry; import com.android.launcher3.widget.model.WidgetsListContentEntry; @@ -67,6 +70,7 @@ public final class WidgetsListAdapterTest { private WidgetsListAdapter mAdapter; private InvariantDeviceProfile mTestProfile; + private UserHandle mUserHandle; private Context mContext; @Before @@ -76,6 +80,7 @@ public final class WidgetsListAdapterTest { mTestProfile = new InvariantDeviceProfile(); mTestProfile.numRows = 5; mTestProfile.numColumns = 5; + mUserHandle = Process.myUserHandle(); mAdapter = new WidgetsListAdapter(mContext, mMockLayoutInflater, mMockWidgetCache, mIconCache, null, null); mAdapter.registerAdapterDataObserver(mListener); @@ -126,7 +131,8 @@ public final class WidgetsListAdapterTest { mAdapter.setWidgets(generateSampleMap(3)); // WHEN com.google.test.1 header is expanded. - mAdapter.onHeaderClicked(/* isExpanded= */ true, TEST_PACKAGE_PLACEHOLDER + 1); + mAdapter.onHeaderClicked(/* showWidgets= */ true, + new PackageUserKey(TEST_PACKAGE_PLACEHOLDER + 1, mUserHandle)); // THEN the visible entries list becomes: // [com.google.test0, com.google.test1, com.google.test1 content, com.google.test2] @@ -143,7 +149,8 @@ public final class WidgetsListAdapterTest { // 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); + mAdapter.onHeaderClicked(/* showWidgets= */ true, + new PackageUserKey(TEST_PACKAGE_PLACEHOLDER + 1, mUserHandle)); Mockito.reset(mListener); // WHEN the adapter is updated with the same list of apps but com.google.test1 has 2 widgets @@ -200,6 +207,30 @@ public final class WidgetsListAdapterTest { verify(mListener).onItemRangeRemoved(/* positionStart= */ 3, /* itemCount= */ 1); } + @Test + public void setWidgetsOnSearch_expandedApp_shouldResetExpandedApp() { + // 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]. + ArrayList allEntries = generateSampleMap(2); + mAdapter.setWidgetsOnSearch(allEntries); + // GIVEN com.google.test.1 header is expanded. The visible entries list becomes: + // [com.google.test0, com.google.test1, com.google.test1 content, com.google.test2] + mAdapter.onHeaderClicked(/* showWidgets= */ true, + new PackageUserKey(TEST_PACKAGE_PLACEHOLDER + 1, mUserHandle)); + Mockito.reset(mListener); + + // WHEN same widget entries are set again. + mAdapter.setWidgetsOnSearch(allEntries); + + // THEN expanded app is reset and the visible entries list becomes: + // [com.google.test0, com.google.test1, com.google.test2] + verify(mListener).onItemRangeChanged(eq(1), eq(1), isNull()); + verify(mListener).onItemRangeRemoved(/* positionStart= */ 2, /* itemCount= */ 1); + } + /** * Generates a list of sample widget entries. * diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java index 848630ef5a..e8c11da2e7 100644 --- a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java @@ -18,7 +18,9 @@ package com.android.launcher3.widget.picker; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; import static org.robolectric.Shadows.shadowOf; import android.appwidget.AppWidgetProviderInfo; @@ -26,12 +28,9 @@ 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.R; @@ -41,10 +40,9 @@ 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.util.PackageUserKey; import com.android.launcher3.widget.LauncherAppWidgetProviderInfo; -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; @@ -74,12 +72,13 @@ public final class WidgetsListHeaderViewHolderBinderTest { // testing. private ActivityController mActivityController; private TestActivity mTestActivity; - private FakeOnHeaderClickListener mFakeOnHeaderClickListener = new FakeOnHeaderClickListener(); @Mock private IconCache mIconCache; @Mock private DeviceProfile mDeviceProfile; + @Mock + private OnHeaderClickListener mOnHeaderClickListener; @Before public void setUp() { @@ -99,8 +98,7 @@ public final class WidgetsListHeaderViewHolderBinderTest { }).when(mIconCache).getTitleNoCache(any()); mViewHolderBinder = new WidgetsListHeaderViewHolderBinder( - LayoutInflater.from(mTestActivity), - mFakeOnHeaderClickListener); + LayoutInflater.from(mTestActivity), mOnHeaderClickListener); } @After @@ -125,6 +123,23 @@ public final class WidgetsListHeaderViewHolderBinderTest { assertThat(appSubtitle.getText()).isEqualTo("3 widgets"); } + @Test + public void bindViewHolder_shouldAttachOnHeaderClickListener() { + WidgetsListHeaderHolder viewHolder = mViewHolderBinder.newViewHolder( + new FrameLayout(mTestActivity)); + WidgetsListHeader widgetsListHeader = viewHolder.mWidgetsListHeader; + WidgetsListHeaderEntry entry = generateSampleAppHeader( + APP_NAME, + TEST_PACKAGE, + /* numOfWidgets= */ 3); + + mViewHolderBinder.bindViewHolder(viewHolder, entry); + widgetsListHeader.callOnClick(); + + verify(mOnHeaderClickListener).onHeaderClicked(eq(true), + eq(new PackageUserKey(entry.mPkgItem.packageName, entry.mPkgItem.user))); + } + private WidgetsListHeaderEntry generateSampleAppHeader(String appName, String packageName, int numOfWidgets) { PackageItemInfo appInfo = new PackageItemInfo(packageName); @@ -152,22 +167,4 @@ public final class WidgetsListHeaderViewHolderBinderTest { } 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/WidgetsListSearchHeaderViewHolderBinderTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderViewHolderBinderTest.java new file mode 100644 index 0000000000..07fbfd2a3c --- /dev/null +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderViewHolderBinderTest.java @@ -0,0 +1,171 @@ +/* + * 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.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +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.widget.FrameLayout; +import android.widget.TextView; + +import com.android.launcher3.DeviceProfile; +import com.android.launcher3.InvariantDeviceProfile; +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.util.PackageUserKey; +import com.android.launcher3.widget.LauncherAppWidgetProviderInfo; +import com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry; + +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 WidgetsListSearchHeaderViewHolderBinderTest { + private static final String TEST_PACKAGE = "com.google.test"; + private static final String APP_NAME = "Test app"; + + private Context mContext; + private WidgetsListSearchHeaderViewHolderBinder mViewHolderBinder; + private InvariantDeviceProfile mTestProfile; + // Replace ActivityController with ActivityScenario, which is the recommended way for activity + // testing. + private ActivityController mActivityController; + private TestActivity mTestActivity; + + @Mock + private IconCache mIconCache; + @Mock + private DeviceProfile mDeviceProfile; + @Mock + private OnHeaderClickListener mOnHeaderClickListener; + + @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 WidgetsListSearchHeaderViewHolderBinder( + LayoutInflater.from(mTestActivity), mOnHeaderClickListener); + } + + @After + public void tearDown() { + mActivityController.destroy(); + } + + @Test + public void bindViewHolder_appWith3Widgets_shouldShowTheCorrectAppNameAndSubtitle() { + WidgetsListSearchHeaderHolder viewHolder = mViewHolderBinder.newViewHolder( + new FrameLayout(mTestActivity)); + WidgetsListHeader widgetsListHeader = viewHolder.mWidgetsListHeader; + WidgetsListSearchHeaderEntry entry = generateSampleSearchHeader( + 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(".SampleWidget0, .SampleWidget1, .SampleWidget2"); + } + + @Test + public void bindViewHolder_shouldAttachOnHeaderClickListener() { + WidgetsListSearchHeaderHolder viewHolder = mViewHolderBinder.newViewHolder( + new FrameLayout(mTestActivity)); + WidgetsListHeader widgetsListHeader = viewHolder.mWidgetsListHeader; + WidgetsListSearchHeaderEntry entry = generateSampleSearchHeader( + APP_NAME, + TEST_PACKAGE, + /* numOfWidgets= */ 3); + + mViewHolderBinder.bindViewHolder(viewHolder, entry); + widgetsListHeader.callOnClick(); + + verify(mOnHeaderClickListener).onHeaderClicked(eq(true), + eq(new PackageUserKey(entry.mPkgItem.packageName, entry.mPkgItem.user))); + } + + private WidgetsListSearchHeaderEntry generateSampleSearchHeader(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 WidgetsListSearchHeaderEntry(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; + } +} diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipelineTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipelineTest.java index 8aebf12b89..17ededdb37 100644 --- a/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipelineTest.java +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipelineTest.java @@ -19,8 +19,6 @@ package com.android.launcher3.widget.picker.search; import static android.os.Looper.getMainLooper; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.robolectric.Shadows.shadowOf; @@ -40,6 +38,7 @@ import com.android.launcher3.model.data.PackageItemInfo; import com.android.launcher3.widget.LauncherAppWidgetProviderInfo; import com.android.launcher3.widget.model.WidgetsListContentEntry; import com.android.launcher3.widget.model.WidgetsListHeaderEntry; +import com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry; import org.junit.Before; import org.junit.Test; @@ -56,9 +55,6 @@ import java.util.List; @RunWith(RobolectricTestRunner.class) public class SimpleWidgetsSearchPipelineTest { - private static final SimpleWidgetsSearchPipeline.StringMatcher MATCHER = - SimpleWidgetsSearchPipeline.StringMatcher.getInstance(); - @Mock private IconCache mIconCache; private InvariantDeviceProfile mTestProfile; @@ -73,9 +69,10 @@ public class SimpleWidgetsSearchPipelineTest { @Before public void setUp() { MockitoAnnotations.initMocks(this); - doAnswer(invocation -> ((ComponentWithLabel) invocation.getArgument(0)) - .getComponent().getPackageName()) - .when(mIconCache).getTitleNoCache(any()); + doAnswer(invocation -> { + ComponentWithLabel componentWithLabel = (ComponentWithLabel) invocation.getArgument(0); + return componentWithLabel.getComponent().getShortClassName(); + }).when(mIconCache).getTitleNoCache(any()); mTestProfile = new InvariantDeviceProfile(); mTestProfile.numRows = 5; mTestProfile.numColumns = 5; @@ -85,54 +82,60 @@ public class SimpleWidgetsSearchPipelineTest { createWidgetsHeaderEntry("com.example.android.Calendar", "Calendar", 2); mCalendarContentEntry = createWidgetsContentEntry("com.example.android.Calendar", "Calendar", 2); - mCameraHeaderEntry = createWidgetsHeaderEntry("com.example.android.Camera", "Camera", 5); - mCameraContentEntry = createWidgetsContentEntry("com.example.android.Camera", "Camera", 5); + mCameraHeaderEntry = createWidgetsHeaderEntry("com.example.android.Camera", "Camera", 11); + mCameraContentEntry = createWidgetsContentEntry("com.example.android.Camera", "Camera", 11); mClockHeaderEntry = createWidgetsHeaderEntry("com.example.android.Clock", "Clock", 3); mClockContentEntry = createWidgetsContentEntry("com.example.android.Clock", "Clock", 3); } @Test - public void query_shouldInformCallbackWithResultsMatchedOnAppName() { + public void query_shouldMatchOnAppName() { SimpleWidgetsSearchPipeline pipeline = new SimpleWidgetsSearchPipeline( List.of(mCalendarHeaderEntry, mCalendarContentEntry, mCameraHeaderEntry, mCameraContentEntry, mClockHeaderEntry, mClockContentEntry)); pipeline.query("Ca", results -> - assertEquals(results, List.of(mCalendarHeaderEntry, mCalendarContentEntry, - mCameraHeaderEntry, mCameraContentEntry))); + assertEquals(results, + List.of( + new WidgetsListSearchHeaderEntry( + mCalendarHeaderEntry.mPkgItem, + mCalendarHeaderEntry.mTitleSectionName, + mCalendarHeaderEntry.mWidgets), + mCalendarContentEntry, + new WidgetsListSearchHeaderEntry( + mCameraHeaderEntry.mPkgItem, + mCameraHeaderEntry.mTitleSectionName, + mCameraHeaderEntry.mWidgets), + mCameraContentEntry))); shadowOf(getMainLooper()).idle(); } @Test - public void testMatches() { - assertTrue(MATCHER.matches("q", "Q")); - assertTrue(MATCHER.matches("q", " Q")); - assertTrue(MATCHER.matches("e", "elephant")); - assertTrue(MATCHER.matches("eL", "Elephant")); - assertTrue(MATCHER.matches("elephant ", "elephant")); - assertTrue(MATCHER.matches("whitec", "white cow")); - assertTrue(MATCHER.matches("white c", "white cow")); - assertTrue(MATCHER.matches("white ", "white cow")); - assertTrue(MATCHER.matches("white c", "white cow")); - assertTrue(MATCHER.matches("电", "电子邮件")); - assertTrue(MATCHER.matches("电子", "电子邮件")); - assertTrue(MATCHER.matches("다", "다운로드")); - assertTrue(MATCHER.matches("드", "드라이브")); - assertTrue(MATCHER.matches("åbç", "abc")); - assertTrue(MATCHER.matches("ål", "Alpha")); + public void query_shouldMatchOnWidgetLabel() { + SimpleWidgetsSearchPipeline pipeline = new SimpleWidgetsSearchPipeline( + List.of(mCalendarHeaderEntry, mCalendarContentEntry, mCameraHeaderEntry, + mCameraContentEntry)); - assertFalse(MATCHER.matches("phant", "elephant")); - assertFalse(MATCHER.matches("elephants", "elephant")); - assertFalse(MATCHER.matches("cow", "white cow")); - assertFalse(MATCHER.matches("cow", "whiteCow")); - assertFalse(MATCHER.matches("dog", "cats&Dogs")); - assertFalse(MATCHER.matches("ba", "Bot")); - assertFalse(MATCHER.matches("ba", "bot")); - assertFalse(MATCHER.matches("子", "电子邮件")); - assertFalse(MATCHER.matches("邮件", "电子邮件")); - assertFalse(MATCHER.matches("ㄷ", "다운로드 드라이브")); - assertFalse(MATCHER.matches("ㄷㄷ", "다운로드 드라이브")); - assertFalse(MATCHER.matches("åç", "abc")); + pipeline.query("Widget1", results -> + assertEquals(results, + List.of( + new WidgetsListSearchHeaderEntry( + mCalendarHeaderEntry.mPkgItem, + mCalendarHeaderEntry.mTitleSectionName, + mCalendarHeaderEntry.mWidgets.subList(1, 2)), + new WidgetsListContentEntry( + mCalendarHeaderEntry.mPkgItem, + mCalendarHeaderEntry.mTitleSectionName, + mCalendarHeaderEntry.mWidgets.subList(1, 2)), + new WidgetsListSearchHeaderEntry( + mCameraHeaderEntry.mPkgItem, + mCameraHeaderEntry.mTitleSectionName, + mCameraHeaderEntry.mWidgets.subList(1, 3)), + new WidgetsListContentEntry( + mCameraHeaderEntry.mPkgItem, + mCameraHeaderEntry.mTitleSectionName, + mCameraHeaderEntry.mWidgets.subList(1, 3))))); + shadowOf(getMainLooper()).idle(); } private WidgetsListHeaderEntry createWidgetsHeaderEntry(String packageName, String appName, diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarControllerTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarControllerTest.java new file mode 100644 index 0000000000..7fc965051d --- /dev/null +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarControllerTest.java @@ -0,0 +1,140 @@ +/* + * 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.search; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import android.content.Context; +import android.view.View; +import android.widget.EditText; +import android.widget.ImageButton; + +import com.android.launcher3.search.SearchAlgorithm; +import com.android.launcher3.widget.model.WidgetsListBaseEntry; + +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 java.util.ArrayList; + +@RunWith(RobolectricTestRunner.class) +public class WidgetsSearchBarControllerTest { + + private WidgetsSearchBarController mController; + private Context mContext; + private EditText mEditText; + private ImageButton mCancelButton; + @Mock + private SearchModeListener mSearchModeListener; + @Mock + private SearchAlgorithm mSearchAlgorithm; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = RuntimeEnvironment.application; + mEditText = new EditText(mContext); + mCancelButton = new ImageButton(mContext); + mController = new WidgetsSearchBarController( + mSearchAlgorithm, mEditText, mCancelButton, mSearchModeListener); + } + + @Test + public void onSearchResult_shouldInformSearchModeListener() { + ArrayList entries = new ArrayList<>(); + mController.onSearchResult("abc", entries); + + verify(mSearchModeListener).onSearchResults(entries); + } + + @Test + public void afterTextChanged_shouldInformSearchModeListenerToEnterSearch() { + mEditText.setText("abc"); + + verify(mSearchModeListener).enterSearchMode(); + verifyNoMoreInteractions(mSearchModeListener); + } + + @Test + public void afterTextChanged_shouldDoSearch() { + mEditText.setText("abc"); + + verify(mSearchAlgorithm).doSearch(eq("abc"), any()); + } + + @Test + public void afterTextChanged_shouldShowCancelButton() { + mEditText.setText("abc"); + + assertEquals(mCancelButton.getVisibility(), View.VISIBLE); + } + + @Test + public void afterTextChanged_empty_shouldInformSearchModeListenerToExitSearch() { + mEditText.setText(""); + + verify(mSearchModeListener).exitSearchMode(); + verifyNoMoreInteractions(mSearchModeListener); + } + + @Test + public void afterTextChanged_empty_shouldCancelSearch() { + mEditText.setText(""); + + verify(mSearchAlgorithm).cancel(true); + verifyNoMoreInteractions(mSearchAlgorithm); + } + + @Test + public void afterTextChanged_empty_shouldHideCancelButton() { + mEditText.setText(""); + + assertEquals(mCancelButton.getVisibility(), View.GONE); + } + + @Test + public void cancelSearch_shouldInformSearchModeListenerToExitSearch() { + mCancelButton.performClick(); + + verify(mSearchModeListener).exitSearchMode(); + verifyNoMoreInteractions(mSearchModeListener); + } + + @Test + public void cancelSearch_shouldCancelSearch() { + mCancelButton.performClick(); + + verify(mSearchAlgorithm).cancel(true); + verifyNoMoreInteractions(mSearchAlgorithm); + } + + @Test + public void cancelSearch_shouldClearSearchBar() { + mCancelButton.performClick(); + + assertEquals(mEditText.getText().toString(), ""); + } +} diff --git a/src/com/android/launcher3/allapps/search/AppsSearchPipeline.java b/src/com/android/launcher3/allapps/search/AppsSearchPipeline.java index f9fb22ece7..34895ed5f5 100644 --- a/src/com/android/launcher3/allapps/search/AppsSearchPipeline.java +++ b/src/com/android/launcher3/allapps/search/AppsSearchPipeline.java @@ -25,6 +25,7 @@ import com.android.launcher3.model.AllAppsList; import com.android.launcher3.model.BaseModelUpdateTask; import com.android.launcher3.model.BgDataModel; import com.android.launcher3.model.data.AppInfo; +import com.android.launcher3.search.StringMatcherUtility; import java.util.ArrayList; import java.util.List; @@ -67,10 +68,10 @@ public class AppsSearchPipeline implements SearchPipeline { // apps that don't match all of the words in the query. final String queryTextLower = query.toLowerCase(); final ArrayList result = new ArrayList<>(); - DefaultAppSearchAlgorithm.StringMatcher matcher = - DefaultAppSearchAlgorithm.StringMatcher.getInstance(); + StringMatcherUtility.StringMatcher matcher = + StringMatcherUtility.StringMatcher.getInstance(); for (AppInfo info : apps) { - if (DefaultAppSearchAlgorithm.matches(info, queryTextLower, matcher)) { + if (StringMatcherUtility.matches(queryTextLower, info.title.toString(), matcher)) { result.add(info); } } diff --git a/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithm.java b/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithm.java index 4e213b0a1b..a386ef8ee2 100644 --- a/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithm.java +++ b/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithm.java @@ -20,12 +20,9 @@ import android.os.Handler; import com.android.launcher3.LauncherAppState; import com.android.launcher3.allapps.AllAppsGridAdapter.AdapterItem; -import com.android.launcher3.model.data.AppInfo; import com.android.launcher3.search.SearchAlgorithm; import com.android.launcher3.search.SearchCallback; -import java.text.Collator; - /** * The default search implementation. */ @@ -54,132 +51,4 @@ public class DefaultAppSearchAlgorithm implements SearchAlgorithm { () -> callback.onSearchResult(query, results)), null); } - - public static boolean matches(AppInfo info, String query, StringMatcher matcher) { - int queryLength = query.length(); - - String title = info.title.toString(); - int titleLength = title.length(); - - if (titleLength < queryLength || queryLength <= 0) { - return false; - } - - if (requestSimpleFuzzySearch(query)) { - return title.toLowerCase().contains(query); - } - - int lastType; - int thisType = Character.UNASSIGNED; - int nextType = Character.getType(title.codePointAt(0)); - - int end = titleLength - queryLength; - for (int i = 0; i <= end; i++) { - lastType = thisType; - thisType = nextType; - nextType = i < (titleLength - 1) ? - Character.getType(title.codePointAt(i + 1)) : Character.UNASSIGNED; - if (isBreak(thisType, lastType, nextType) && - matcher.matches(query, title.substring(i, i + queryLength))) { - return true; - } - } - return false; - } - - /** - * Returns true if the current point should be a break point. Following cases - * are considered as break points: - * 1) Any non space character after a space character - * 2) Any digit after a non-digit character - * 3) Any capital character after a digit or small character - * 4) Any capital character before a small character - */ - private static boolean isBreak(int thisType, int prevType, int nextType) { - switch (prevType) { - case Character.UNASSIGNED: - case Character.SPACE_SEPARATOR: - case Character.LINE_SEPARATOR: - case Character.PARAGRAPH_SEPARATOR: - return true; - } - switch (thisType) { - case Character.UPPERCASE_LETTER: - if (nextType == Character.UPPERCASE_LETTER) { - return true; - } - // Follow through - case Character.TITLECASE_LETTER: - // Break point if previous was not a upper case - return prevType != Character.UPPERCASE_LETTER; - case Character.LOWERCASE_LETTER: - // Break point if previous was not a letter. - return prevType > Character.OTHER_LETTER || prevType <= Character.UNASSIGNED; - case Character.DECIMAL_DIGIT_NUMBER: - case Character.LETTER_NUMBER: - case Character.OTHER_NUMBER: - // Break point if previous was not a number - return !(prevType == Character.DECIMAL_DIGIT_NUMBER - || prevType == Character.LETTER_NUMBER - || prevType == Character.OTHER_NUMBER); - case Character.MATH_SYMBOL: - case Character.CURRENCY_SYMBOL: - case Character.OTHER_PUNCTUATION: - case Character.DASH_PUNCTUATION: - // Always a break point for a symbol - return true; - default: - return false; - } - } - - public static class StringMatcher { - - private static final char MAX_UNICODE = '\uFFFF'; - - private final Collator mCollator; - - StringMatcher() { - // On android N and above, Collator uses ICU implementation which has a much better - // support for non-latin locales. - mCollator = Collator.getInstance(); - mCollator.setStrength(Collator.PRIMARY); - mCollator.setDecomposition(Collator.CANONICAL_DECOMPOSITION); - } - - /** - * Returns true if {@param query} is a prefix of {@param target} - */ - public boolean matches(String query, String target) { - switch (mCollator.compare(query, target)) { - case 0: - return true; - case -1: - // The target string can contain a modifier which would make it larger than - // the query string (even though the length is same). If the query becomes - // larger after appending a unicode character, it was originally a prefix of - // the target string and hence should match. - return mCollator.compare(query + MAX_UNICODE, target) > -1; - default: - return false; - } - } - - public static StringMatcher getInstance() { - return new StringMatcher(); - } - } - - private static boolean requestSimpleFuzzySearch(String s) { - for (int i = 0; i < s.length(); ) { - int codepoint = s.codePointAt(i); - i += Character.charCount(codepoint); - switch (Character.UnicodeScript.of(codepoint)) { - case HAN: - //Character.UnicodeScript.HAN: use String.contains to match - return true; - } - } - return false; - } } diff --git a/src/com/android/launcher3/search/SearchAlgorithm.java b/src/com/android/launcher3/search/SearchAlgorithm.java index 1665354af2..a1720c733b 100644 --- a/src/com/android/launcher3/search/SearchAlgorithm.java +++ b/src/com/android/launcher3/search/SearchAlgorithm.java @@ -31,4 +31,9 @@ public interface SearchAlgorithm { * Cancels any active request. */ void cancel(boolean interruptActiveRequests); + + /** + * Cleans up after search is no longer needed. + */ + default void destroy() {}; } diff --git a/src/com/android/launcher3/search/StringMatcherUtility.java b/src/com/android/launcher3/search/StringMatcherUtility.java new file mode 100644 index 0000000000..acab52bbe8 --- /dev/null +++ b/src/com/android/launcher3/search/StringMatcherUtility.java @@ -0,0 +1,162 @@ +/* + * 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.search; + +import java.text.Collator; + +/** + * Utilities for matching query string to target string. + */ +public class StringMatcherUtility { + + /** + * Returns {@code true} is {@code query} is a prefix substring of a complete word/phrase in + * {@code target}. + */ + public static boolean matches(String query, String target, StringMatcher matcher) { + int queryLength = query.length(); + + int targetLength = target.length(); + + if (targetLength < queryLength || queryLength <= 0) { + return false; + } + + if (requestSimpleFuzzySearch(query)) { + return target.toLowerCase().contains(query); + } + + int lastType; + int thisType = Character.UNASSIGNED; + int nextType = Character.getType(target.codePointAt(0)); + + int end = targetLength - queryLength; + for (int i = 0; i <= end; i++) { + lastType = thisType; + thisType = nextType; + nextType = i < (targetLength - 1) + ? Character.getType(target.codePointAt(i + 1)) : Character.UNASSIGNED; + if (isBreak(thisType, lastType, nextType) + && matcher.matches(query, target.substring(i, i + queryLength))) { + return true; + } + } + return false; + } + + /** + * Returns true if the current point should be a break point. Following cases + * are considered as break points: + * 1) Any non space character after a space character + * 2) Any digit after a non-digit character + * 3) Any capital character after a digit or small character + * 4) Any capital character before a small character + */ + private static boolean isBreak(int thisType, int prevType, int nextType) { + switch (prevType) { + case Character.UNASSIGNED: + case Character.SPACE_SEPARATOR: + case Character.LINE_SEPARATOR: + case Character.PARAGRAPH_SEPARATOR: + return true; + } + switch (thisType) { + case Character.UPPERCASE_LETTER: + if (nextType == Character.UPPERCASE_LETTER) { + return true; + } + // Follow through + case Character.TITLECASE_LETTER: + // Break point if previous was not a upper case + return prevType != Character.UPPERCASE_LETTER; + case Character.LOWERCASE_LETTER: + // Break point if previous was not a letter. + return prevType > Character.OTHER_LETTER || prevType <= Character.UNASSIGNED; + case Character.DECIMAL_DIGIT_NUMBER: + case Character.LETTER_NUMBER: + case Character.OTHER_NUMBER: + // Break point if previous was not a number + return !(prevType == Character.DECIMAL_DIGIT_NUMBER + || prevType == Character.LETTER_NUMBER + || prevType == Character.OTHER_NUMBER); + case Character.MATH_SYMBOL: + case Character.CURRENCY_SYMBOL: + case Character.OTHER_PUNCTUATION: + case Character.DASH_PUNCTUATION: + // Always a break point for a symbol + return true; + default: + return false; + } + } + + /** + * Performs locale sensitive string comparison using {@link Collator}. + */ + public static class StringMatcher { + + private static final char MAX_UNICODE = '\uFFFF'; + + private final Collator mCollator; + + StringMatcher() { + // On android N and above, Collator uses ICU implementation which has a much better + // support for non-latin locales. + mCollator = Collator.getInstance(); + mCollator.setStrength(Collator.PRIMARY); + mCollator.setDecomposition(Collator.CANONICAL_DECOMPOSITION); + } + + /** + * Returns true if {@param query} is a prefix of {@param target} + */ + public boolean matches(String query, String target) { + switch (mCollator.compare(query, target)) { + case 0: + return true; + case -1: + // The target string can contain a modifier which would make it larger than + // the query string (even though the length is same). If the query becomes + // larger after appending a unicode character, it was originally a prefix of + // the target string and hence should match. + return mCollator.compare(query + MAX_UNICODE, target) > -1; + default: + return false; + } + } + + public static StringMatcher getInstance() { + return new StringMatcher(); + } + } + + /** + * Matching optimization to search in Chinese. + */ + private static boolean requestSimpleFuzzySearch(String s) { + for (int i = 0; i < s.length(); ) { + int codepoint = s.codePointAt(i); + i += Character.charCount(codepoint); + switch (Character.UnicodeScript.of(codepoint)) { + case HAN: + //Character.UnicodeScript.HAN: use String.contains to match + return true; + } + } + return false; + } +} diff --git a/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java b/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java index 09517e1cfb..73bae6f484 100644 --- a/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java +++ b/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java @@ -20,10 +20,14 @@ import static java.lang.annotation.RetentionPolicy.SOURCE; import androidx.annotation.IntDef; +import com.android.launcher3.model.WidgetItem; import com.android.launcher3.model.data.ItemInfo; import com.android.launcher3.model.data.PackageItemInfo; +import com.android.launcher3.widget.WidgetItemComparator; import java.lang.annotation.Retention; +import java.util.List; +import java.util.stream.Collectors; /** Holder class to store the package information of an entry shown in the widgets list. */ public abstract class WidgetsListBaseEntry { @@ -35,9 +39,14 @@ public abstract class WidgetsListBaseEntry { */ public final String mTitleSectionName; - public WidgetsListBaseEntry(PackageItemInfo pkgItem, String titleSectionName) { + public final List mWidgets; + + public WidgetsListBaseEntry(PackageItemInfo pkgItem, String titleSectionName, + List items) { mPkgItem = pkgItem; mTitleSectionName = titleSectionName; + this.mWidgets = + items.stream().sorted(new WidgetItemComparator()).collect(Collectors.toList()); } /** @@ -51,10 +60,11 @@ public abstract class WidgetsListBaseEntry { public abstract int getRank(); @Retention(SOURCE) - @IntDef({RANK_WIDGETS_LIST_HEADER, RANK_WIDGETS_LIST_CONTENT}) + @IntDef({RANK_WIDGETS_LIST_HEADER, RANK_WIDGETS_LIST_SEARCH_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; + public static final int RANK_WIDGETS_LIST_SEARCH_HEADER = 2; + public static final int RANK_WIDGETS_LIST_CONTENT = 3; } diff --git a/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java b/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java index b0cb8c7455..0328cf68ba 100644 --- a/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java +++ b/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java @@ -17,10 +17,8 @@ package com.android.launcher3.widget.model; import com.android.launcher3.model.WidgetItem; import com.android.launcher3.model.data.PackageItemInfo; -import com.android.launcher3.widget.WidgetItemComparator; import java.util.List; -import java.util.stream.Collectors; /** * Holder class to store all the information related to a list of widgets from the same app which is @@ -28,18 +26,14 @@ import java.util.stream.Collectors; */ public final class WidgetsListContentEntry extends WidgetsListBaseEntry { - public final List mWidgets; - public WidgetsListContentEntry(PackageItemInfo pkgItem, String titleSectionName, List items) { - super(pkgItem, titleSectionName); - this.mWidgets = - items.stream().sorted(new WidgetItemComparator()).collect(Collectors.toList()); + super(pkgItem, titleSectionName, items); } @Override public String toString() { - return mPkgItem.packageName + ":" + mWidgets.size(); + return "Content:" + mPkgItem.packageName + ":" + mWidgets.size(); } @Override @@ -47,4 +41,12 @@ public final class WidgetsListContentEntry extends WidgetsListBaseEntry { public int getRank() { return RANK_WIDGETS_LIST_CONTENT; } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof WidgetsListContentEntry)) return false; + WidgetsListContentEntry otherEntry = (WidgetsListContentEntry) obj; + return mWidgets.equals(otherEntry.mWidgets) && mPkgItem.equals(otherEntry.mPkgItem) + && mTitleSectionName.equals(otherEntry.mTitleSectionName); + } } diff --git a/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java b/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java index 6899647764..1fdc39959a 100644 --- a/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java +++ b/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java @@ -18,7 +18,7 @@ package com.android.launcher3.widget.model; import com.android.launcher3.model.WidgetItem; import com.android.launcher3.model.data.PackageItemInfo; -import java.util.Collection; +import java.util.List; /** An information holder for an app which has widgets or/and shortcuts. */ public final class WidgetsListHeaderEntry extends WidgetsListBaseEntry { @@ -30,8 +30,8 @@ public final class WidgetsListHeaderEntry extends WidgetsListBaseEntry { private boolean mHasEntryUpdated = false; public WidgetsListHeaderEntry(PackageItemInfo pkgItem, String titleSectionName, - Collection items) { - super(pkgItem, titleSectionName); + List items) { + super(pkgItem, titleSectionName, items); widgetsCount = (int) items.stream().filter(item -> item.widgetInfo != null).count(); shortcutsCount = Math.max(0, items.size() - widgetsCount); } @@ -56,9 +56,22 @@ public final class WidgetsListHeaderEntry extends WidgetsListBaseEntry { return mHasEntryUpdated; } + @Override + public String toString() { + return "Header:" + mPkgItem.packageName + ":" + mWidgets.size(); + } + @Override @Rank public int getRank() { return RANK_WIDGETS_LIST_HEADER; } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof WidgetsListHeaderEntry)) return false; + WidgetsListHeaderEntry otherEntry = (WidgetsListHeaderEntry) obj; + return mWidgets.equals(otherEntry.mWidgets) && mPkgItem.equals(otherEntry.mPkgItem) + && mTitleSectionName.equals(otherEntry.mTitleSectionName); + } } diff --git a/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java b/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java new file mode 100644 index 0000000000..2aec3f8fd9 --- /dev/null +++ b/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java @@ -0,0 +1,72 @@ +/* + * 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.List; + +/** An information holder for an app which has widgets or/and shortcuts, to be shown in search. */ +public final class WidgetsListSearchHeaderEntry extends WidgetsListBaseEntry { + + private boolean mIsWidgetListShown = false; + private boolean mHasEntryUpdated = false; + + public WidgetsListSearchHeaderEntry(PackageItemInfo pkgItem, String titleSectionName, + List items) { + super(pkgItem, titleSectionName, items); + } + + /** 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 + public String toString() { + return "SearchHeader:" + mPkgItem.packageName + ":" + mWidgets.size(); + } + + @Override + @Rank + public int getRank() { + return RANK_WIDGETS_LIST_SEARCH_HEADER; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof WidgetsListSearchHeaderEntry)) return false; + WidgetsListSearchHeaderEntry otherEntry = (WidgetsListSearchHeaderEntry) obj; + return mWidgets.equals(otherEntry.mWidgets) && mPkgItem.equals(otherEntry.mPkgItem) + && mTitleSectionName.equals(otherEntry.mTitleSectionName); + } +} diff --git a/src/com/android/launcher3/widget/picker/OnHeaderClickListener.java b/src/com/android/launcher3/widget/picker/OnHeaderClickListener.java new file mode 100644 index 0000000000..73727515c2 --- /dev/null +++ b/src/com/android/launcher3/widget/picker/OnHeaderClickListener.java @@ -0,0 +1,28 @@ +/* + * 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 com.android.launcher3.util.PackageUserKey; + +/** + * A listener to be invoked when a header is clicked. + */ +public interface OnHeaderClickListener { + /** + * Calls when a header is clicked to show / hide widgets for a package. + */ + void onHeaderClicked(boolean showWidgets, PackageUserKey packageUserKey); +} diff --git a/src/com/android/launcher3/widget/picker/SearchAndRecommendationsScrollController.java b/src/com/android/launcher3/widget/picker/SearchAndRecommendationsScrollController.java index 95fa05f19e..7eb5b834fd 100644 --- a/src/com/android/launcher3/widget/picker/SearchAndRecommendationsScrollController.java +++ b/src/com/android/launcher3/widget/picker/SearchAndRecommendationsScrollController.java @@ -34,6 +34,7 @@ final class SearchAndRecommendationsScrollController implements private final boolean mHasWorkProfile; private final SearchAndRecommendationViewHolder mViewHolder; private final WidgetsRecyclerView mPrimaryRecyclerView; + private final WidgetsRecyclerView mSearchRecyclerView; // The following are only non null if mHasWorkProfile is true. @Nullable private final WidgetsRecyclerView mWorkRecyclerView; @@ -48,12 +49,14 @@ final class SearchAndRecommendationsScrollController implements SearchAndRecommendationViewHolder viewHolder, WidgetsRecyclerView primaryRecyclerView, @Nullable WidgetsRecyclerView workRecyclerView, + WidgetsRecyclerView searchRecyclerView, @Nullable View personalWorkTabsView, @Nullable PersonalWorkPagedView primaryWorkViewPager) { mHasWorkProfile = hasWorkProfile; mViewHolder = viewHolder; mPrimaryRecyclerView = primaryRecyclerView; mWorkRecyclerView = workRecyclerView; + mSearchRecyclerView = searchRecyclerView; mPrimaryWorkTabsView = personalWorkTabsView; mPrimaryWorkViewPager = primaryWorkViewPager; mCurrentRecyclerView = mPrimaryRecyclerView; @@ -149,6 +152,11 @@ final class SearchAndRecommendationsScrollController implements mPrimaryRecyclerView.getPaddingRight(), mPrimaryRecyclerView.getPaddingBottom()); } + mSearchRecyclerView.setPadding( + mSearchRecyclerView.getPaddingLeft(), + topContainerHeight, + mSearchRecyclerView.getPaddingRight(), + mSearchRecyclerView.getPaddingBottom()); } /** diff --git a/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java b/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java index dbd1bdf523..2366609b3f 100644 --- a/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java +++ b/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java @@ -25,6 +25,7 @@ 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.model.WidgetsListSearchHeaderEntry; import com.android.launcher3.widget.picker.WidgetsListAdapter.WidgetListBaseRowEntryComparator; import java.util.ArrayList; @@ -113,7 +114,7 @@ public class WidgetsDiffReporter { // 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) - || hasHeaderUpdated(newRowEntry) + || hasHeaderUpdated(orgRowEntry, newRowEntry) || hasWidgetsListChanged(orgRowEntry, newRowEntry)) { index = currentEntries.indexOf(orgRowEntry); currentEntries.set(index, newRowEntry); @@ -174,12 +175,16 @@ public class WidgetsDiffReporter { * 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; + private boolean hasHeaderUpdated(WidgetsListBaseEntry curRow, WidgetsListBaseEntry newRow) { + if (newRow instanceof WidgetsListHeaderEntry && curRow instanceof WidgetsListHeaderEntry) { + return ((WidgetsListHeaderEntry) newRow).hasEntryUpdated() || !curRow.equals(newRow); } - WidgetsListHeaderEntry newRowEntry = (WidgetsListHeaderEntry) newRow; - return newRowEntry.hasEntryUpdated(); + if (newRow instanceof WidgetsListSearchHeaderEntry + && curRow instanceof WidgetsListSearchHeaderEntry) { + return ((WidgetsListSearchHeaderEntry) newRow).hasEntryUpdated() + || !curRow.equals(newRow); + } + return false; } private boolean isSamePackageItemInfo(PackageItemInfo curInfo, PackageItemInfo newInfo) { diff --git a/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java b/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java index 978c6d82ce..6b3c71ab36 100644 --- a/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java +++ b/src/com/android/launcher3/widget/picker/WidgetsFullSheet.java @@ -34,7 +34,6 @@ import android.view.MotionEvent; import android.view.View; import android.view.animation.AnimationUtils; import android.view.animation.Interpolator; -import android.widget.EditText; import android.widget.TextView; import androidx.annotation.Nullable; @@ -53,6 +52,8 @@ import com.android.launcher3.views.TopRoundedCornerView; import com.android.launcher3.widget.BaseWidgetSheet; import com.android.launcher3.widget.LauncherAppWidgetHost.ProviderChangedListener; import com.android.launcher3.widget.model.WidgetsListBaseEntry; +import com.android.launcher3.widget.picker.search.SearchModeListener; +import com.android.launcher3.widget.picker.search.WidgetsSearchBar; import com.android.launcher3.workprofile.PersonalWorkPagedView; import com.android.launcher3.workprofile.PersonalWorkSlidingTabStrip.OnActivePageChangedListener; @@ -64,7 +65,7 @@ import java.util.function.Predicate; */ public class WidgetsFullSheet extends BaseWidgetSheet implements Insettable, ProviderChangedListener, OnActivePageChangedListener, - WidgetsRecyclerView.HeaderViewDimensionsProvider { + WidgetsRecyclerView.HeaderViewDimensionsProvider, SearchModeListener { private static final long DEFAULT_OPEN_DURATION = 267; private static final long FADE_IN_DURATION = 150; @@ -81,6 +82,7 @@ public class WidgetsFullSheet extends BaseWidgetSheet @Nullable private PersonalWorkPagedView mViewPager; private int mInitialTabsHeight = 0; + private boolean mIsInSearchMode; private View mTabsView; private TextView mNoWidgetsView; private SearchAndRecommendationViewHolder mSearchAndRecommendationViewHolder; @@ -91,6 +93,7 @@ public class WidgetsFullSheet extends BaseWidgetSheet mHasWorkProfile = context.getSystemService(LauncherApps.class).getProfiles().size() > 1; mAdapters.put(AdapterHolder.PRIMARY, new AdapterHolder(AdapterHolder.PRIMARY)); mAdapters.put(AdapterHolder.WORK, new AdapterHolder(AdapterHolder.WORK)); + mAdapters.put(AdapterHolder.SEARCH, new AdapterHolder(AdapterHolder.SEARCH)); } public WidgetsFullSheet(Context context, AttributeSet attrs) { @@ -138,6 +141,7 @@ public class WidgetsFullSheet extends BaseWidgetSheet mSearchAndRecommendationViewHolder, findViewById(R.id.primary_widgets_list_view), mHasWorkProfile ? findViewById(R.id.work_widgets_list_view) : null, + findViewById(R.id.search_widgets_list_view), mTabsView, mViewPager); fastScroller.setOnFastScrollChangeListener(mSearchAndRecommendationsScrollController); @@ -145,17 +149,25 @@ public class WidgetsFullSheet extends BaseWidgetSheet mNoWidgetsView = findViewById(R.id.no_widgets_text); onWidgetsBound(); + + mSearchAndRecommendationViewHolder.mSearchBar.initialize( + mLauncher.getPopupDataProvider().getAllWidgets(), /* searchModeListener= */ this); } @Override public void onActivePageChanged(int currentActivePage) { AdapterHolder currentAdapterHolder = mAdapters.get(currentActivePage); - WidgetsRecyclerView currentRecyclerView = currentAdapterHolder.mWidgetsRecyclerView; - currentRecyclerView.bindFastScrollbar(); - mSearchAndRecommendationsScrollController.setCurrentRecyclerView(currentRecyclerView); + WidgetsRecyclerView currentRecyclerView = + mAdapters.get(currentActivePage).mWidgetsRecyclerView; updateNoWidgetsView(currentAdapterHolder); + attachScrollbarToRecyclerView(currentRecyclerView); + } + + private void attachScrollbarToRecyclerView(WidgetsRecyclerView recyclerView) { + recyclerView.bindFastScrollbar(); + mSearchAndRecommendationsScrollController.setCurrentRecyclerView(recyclerView); reset(); } @@ -173,11 +185,15 @@ public class WidgetsFullSheet extends BaseWidgetSheet if (mHasWorkProfile) { mAdapters.get(AdapterHolder.WORK).mWidgetsRecyclerView.scrollToTop(); } + mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView.scrollToTop(); mSearchAndRecommendationsScrollController.reset(); } @VisibleForTesting public WidgetsRecyclerView getRecyclerView() { + if (mIsInSearchMode) { + return mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView; + } if (!mHasWorkProfile || mViewPager.getCurrentPage() == AdapterHolder.PRIMARY) { return mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView; } @@ -289,6 +305,8 @@ public class WidgetsFullSheet extends BaseWidgetSheet AdapterHolder primaryUserAdapterHolder = mAdapters.get(AdapterHolder.PRIMARY); primaryUserAdapterHolder.setup(findViewById(R.id.primary_widgets_list_view)); + AdapterHolder searchAdapterHolder = mAdapters.get(AdapterHolder.SEARCH); + searchAdapterHolder.setup(findViewById(R.id.search_widgets_list_view)); primaryUserAdapterHolder.mWidgetsListAdapter.setWidgets(allWidgets); updateNoWidgetsView(primaryUserAdapterHolder); @@ -300,6 +318,40 @@ public class WidgetsFullSheet extends BaseWidgetSheet } } + @Override + public void enterSearchMode() { + if (mIsInSearchMode) return; + setViewVisibilityBasedOnSearch(/*isInSearchMode= */ true); + attachScrollbarToRecyclerView(mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView); + } + + @Override + public void exitSearchMode() { + setViewVisibilityBasedOnSearch(/*isInSearchMode=*/ false); + if (mHasWorkProfile) { + mViewPager.snapToPage(AdapterHolder.PRIMARY); + } + attachScrollbarToRecyclerView(mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView); + } + + @Override + public void onSearchResults(List entries) { + mAdapters.get(AdapterHolder.SEARCH).mWidgetsListAdapter.setWidgetsOnSearch(entries); + } + + private void setViewVisibilityBasedOnSearch(boolean isInSearchMode) { + mIsInSearchMode = isInSearchMode; + if (mHasWorkProfile) { + mViewPager.setVisibility(isInSearchMode ? GONE : VISIBLE); + mTabsView.setVisibility(isInSearchMode ? GONE : VISIBLE); + } else { + mAdapters.get(AdapterHolder.PRIMARY).mWidgetsRecyclerView + .setVisibility(isInSearchMode ? GONE : VISIBLE); + } + mAdapters.get(AdapterHolder.SEARCH).mWidgetsRecyclerView + .setVisibility(mIsInSearchMode ? VISIBLE : GONE); + } + private void open(boolean animate) { if (animate) { if (getPopupContainer().getInsets().bottom > 0) { @@ -404,6 +456,7 @@ public class WidgetsFullSheet extends BaseWidgetSheet private final class AdapterHolder { static final int PRIMARY = 0; static final int WORK = 1; + static final int SEARCH = 2; private final int mAdapterType; private final WidgetsListAdapter mWidgetsListAdapter; @@ -422,8 +475,16 @@ public class WidgetsFullSheet extends BaseWidgetSheet apps.getIconCache(), /* iconClickListener= */ WidgetsFullSheet.this, /* iconLongClickListener= */ WidgetsFullSheet.this); - mWidgetsListAdapter.setFilter( - mAdapterType == PRIMARY ? mPrimaryWidgetsFilter : mWorkWidgetsFilter); + switch (mAdapterType) { + case PRIMARY: + mWidgetsListAdapter.setFilter(mPrimaryWidgetsFilter); + break; + case WORK: + mWidgetsListAdapter.setFilter(mWorkWidgetsFilter); + break; + default: + break; + } } void setup(WidgetsRecyclerView recyclerView) { @@ -439,7 +500,7 @@ public class WidgetsFullSheet extends BaseWidgetSheet final class SearchAndRecommendationViewHolder { final View mContainer; final View mCollapseHandle; - final EditText mSearchBar; + final WidgetsSearchBar mSearchBar; final TextView mHeaderTitle; SearchAndRecommendationViewHolder(View searchAndRecommendationContainer) { diff --git a/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java b/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java index 00c1f8bd7a..9009eb15c8 100644 --- a/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java +++ b/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java @@ -34,11 +34,12 @@ import com.android.launcher3.WidgetPreviewLoader; import com.android.launcher3.icons.IconCache; import com.android.launcher3.recyclerview.ViewHolderBinder; import com.android.launcher3.util.LabelComparator; +import com.android.launcher3.util.PackageUserKey; 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 com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry; import java.util.ArrayList; import java.util.Comparator; @@ -62,8 +63,9 @@ public class WidgetsListAdapter extends Adapter implements OnHeaderC 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 static final int VIEW_TYPE_WIDGETS_LIST = R.id.view_type_widgets_list; + private static final int VIEW_TYPE_WIDGETS_HEADER = R.id.view_type_widgets_header; + private static final int VIEW_TYPE_WIDGETS_SEARCH_HEADER = R.id.view_type_widgets_search_header; private final WidgetsDiffReporter mDiffReporter; private final SparseArray mViewHolderBinders = new SparseArray<>(); @@ -73,11 +75,13 @@ public class WidgetsListAdapter extends Adapter implements OnHeaderC private List mAllEntries = new ArrayList<>(); private ArrayList mVisibleEntries = new ArrayList<>(); - @Nullable private String mWidgetsContentVisiblePackage = null; + @Nullable private PackageUserKey mWidgetsContentVisiblePackageUserKey = null; private Predicate mHeaderAndSelectedContentFilter = entry -> entry instanceof WidgetsListHeaderEntry - || entry.mPkgItem.packageName.equals(mWidgetsContentVisiblePackage); + || entry instanceof WidgetsListSearchHeaderEntry + || new PackageUserKey(entry.mPkgItem.packageName, entry.mPkgItem.user) + .equals(mWidgetsContentVisiblePackageUserKey); @Nullable private Predicate mFilter = null; public WidgetsListAdapter(Context context, LayoutInflater layoutInflater, @@ -87,8 +91,14 @@ public class WidgetsListAdapter extends Adapter implements OnHeaderC mWidgetsListTableViewHolderBinder = new WidgetsListTableViewHolderBinder(context, layoutInflater, iconClickListener, iconLongClickListener, widgetPreviewLoader); mViewHolderBinders.put(VIEW_TYPE_WIDGETS_LIST, mWidgetsListTableViewHolderBinder); - mViewHolderBinders.put(VIEW_TYPE_WIDGETS_HEADER, - new WidgetsListHeaderViewHolderBinder(layoutInflater, this::onHeaderClicked)); + mViewHolderBinders.put( + VIEW_TYPE_WIDGETS_HEADER, + new WidgetsListHeaderViewHolderBinder( + layoutInflater, /*onHeaderClickListener=*/this)); + mViewHolderBinders.put( + VIEW_TYPE_WIDGETS_SEARCH_HEADER, + new WidgetsListSearchHeaderViewHolderBinder( + layoutInflater, /*onHeaderClickListener=*/ this)); } public void setFilter(Predicate filter) { @@ -132,18 +142,30 @@ public class WidgetsListAdapter extends Adapter implements OnHeaderC return mVisibleEntries.get(pos).mTitleSectionName; } - /** Updates the widget list. */ + /** Updates the widget list based on {@code tempEntries}. */ public void setWidgets(List tempEntries) { mAllEntries = tempEntries.stream().sorted(mRowComparator) .collect(Collectors.toList()); updateVisibleEntries(); } + /** Updates the widget list based on {@code searchResults}. */ + public void setWidgetsOnSearch(List searchResults) { + // Forget the expanded package every time widget list is refreshed in search mode. + mWidgetsContentVisiblePackageUserKey = null; + setWidgets(searchResults); + } + private void updateVisibleEntries() { mAllEntries.forEach(entry -> { if (entry instanceof WidgetsListHeaderEntry) { ((WidgetsListHeaderEntry) entry).setIsWidgetListShown( - entry.mPkgItem.packageName.equals(mWidgetsContentVisiblePackage)); + new PackageUserKey(entry.mPkgItem.packageName, entry.mPkgItem.user) + .equals(mWidgetsContentVisiblePackageUserKey)); + } else if (entry instanceof WidgetsListSearchHeaderEntry) { + ((WidgetsListSearchHeaderEntry) entry).setIsWidgetListShown( + new PackageUserKey(entry.mPkgItem.packageName, entry.mPkgItem.user) + .equals(mWidgetsContentVisiblePackageUserKey)); } }); List newVisibleEntries = mAllEntries.stream() @@ -194,17 +216,19 @@ public class WidgetsListAdapter extends Adapter implements OnHeaderC return VIEW_TYPE_WIDGETS_LIST; } else if (entry instanceof WidgetsListHeaderEntry) { return VIEW_TYPE_WIDGETS_HEADER; + } else if (entry instanceof WidgetsListSearchHeaderEntry) { + return VIEW_TYPE_WIDGETS_SEARCH_HEADER; } throw new UnsupportedOperationException("ViewHolderBinder not found for " + entry); } @Override - public void onHeaderClicked(boolean showWidgets, String expandedPackage) { + public void onHeaderClicked(boolean showWidgets, PackageUserKey packageUserKey) { if (showWidgets) { - mWidgetsContentVisiblePackage = expandedPackage; + mWidgetsContentVisiblePackageUserKey = packageUserKey; updateVisibleEntries(); - } else if (expandedPackage.equals(mWidgetsContentVisiblePackage)) { - mWidgetsContentVisiblePackage = null; + } else if (packageUserKey.equals(mWidgetsContentVisiblePackageUserKey)) { + mWidgetsContentVisiblePackageUserKey = null; updateVisibleEntries(); } } diff --git a/src/com/android/launcher3/widget/picker/WidgetsListHeader.java b/src/com/android/launcher3/widget/picker/WidgetsListHeader.java index 070a9aa2c7..119d094be5 100644 --- a/src/com/android/launcher3/widget/picker/WidgetsListHeader.java +++ b/src/com/android/launcher3/widget/picker/WidgetsListHeader.java @@ -41,6 +41,9 @@ 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; +import com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry; + +import java.util.stream.Collectors; /** * A UI represents a header of an app shown in the full widgets tray. @@ -173,7 +176,7 @@ public final class WidgetsListHeader extends LinearLayout implements ItemInfoUpd shortcutsCount); } else if (entry.widgetsCount > 0) { subtitle = resources.getQuantityString(R.plurals.widgets_count, - entry.widgetsCount, entry.widgetsCount); + entry.widgetsCount, entry.widgetsCount); } else { subtitle = resources.getQuantityString(R.plurals.shortcuts_count, entry.shortcutsCount, entry.shortcutsCount); @@ -182,6 +185,32 @@ public final class WidgetsListHeader extends LinearLayout implements ItemInfoUpd mSubtitle.setVisibility(VISIBLE); } + /** Apply app icon, labels and tag using a generic {@link WidgetsListSearchHeaderEntry}. */ + @UiThread + public void applyFromItemInfoWithIcon(WidgetsListSearchHeaderEntry entry) { + applyIconAndLabel(entry); + } + + @UiThread + private void applyIconAndLabel(WidgetsListSearchHeaderEntry entry) { + PackageItemInfo info = entry.mPkgItem; + setIcon(info); + setTitles(entry); + setExpanded(entry.isWidgetListShown()); + + super.setTag(info); + + verifyHighRes(); + } + + private void setTitles(WidgetsListSearchHeaderEntry entry) { + mTitle.setText(entry.mPkgItem.title); + + mSubtitle.setText(entry.mWidgets.stream() + .map(item -> item.label).sorted().collect(Collectors.joining(", "))); + mSubtitle.setVisibility(VISIBLE); + } + @Override public void reapplyItemInfo(ItemInfoWithIcon info) { if (getTag() == info) { diff --git a/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinder.java b/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinder.java index ed53e6fbc9..fcefe3a5ed 100644 --- a/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinder.java +++ b/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinder.java @@ -20,6 +20,7 @@ import android.view.ViewGroup; import com.android.launcher3.R; import com.android.launcher3.recyclerview.ViewHolderBinder; +import com.android.launcher3.util.PackageUserKey; import com.android.launcher3.widget.model.WidgetsListHeaderEntry; /** @@ -50,12 +51,9 @@ public final class WidgetsListHeaderViewHolderBinder implements 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); + mOnHeaderClickListener.onHeaderClicked( + isExpanded, + new PackageUserKey(data.mPkgItem.packageName, data.mPkgItem.user) + )); } } diff --git a/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderHolder.java b/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderHolder.java new file mode 100644 index 0000000000..9562af3d50 --- /dev/null +++ b/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderHolder.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 WidgetsListSearchHeaderHolder extends ViewHolder { + final WidgetsListHeader mWidgetsListHeader; + + public WidgetsListSearchHeaderHolder(WidgetsListHeader view) { + super(view); + + mWidgetsListHeader = view; + } +} diff --git a/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderViewHolderBinder.java b/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderViewHolderBinder.java new file mode 100644 index 0000000000..83c7948f95 --- /dev/null +++ b/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderViewHolderBinder.java @@ -0,0 +1,59 @@ +/* + * 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.util.PackageUserKey; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; +import com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry; + +/** + * Binds data from {@link WidgetsListHeaderEntry} to UI elements in {@link WidgetsListHeaderHolder}. + */ +public final class WidgetsListSearchHeaderViewHolderBinder implements + ViewHolderBinder { + private final LayoutInflater mLayoutInflater; + private final OnHeaderClickListener mOnHeaderClickListener; + + public WidgetsListSearchHeaderViewHolderBinder(LayoutInflater layoutInflater, + OnHeaderClickListener onHeaderClickListener) { + mLayoutInflater = layoutInflater; + mOnHeaderClickListener = onHeaderClickListener; + } + + @Override + public WidgetsListSearchHeaderHolder newViewHolder(ViewGroup parent) { + WidgetsListHeader header = (WidgetsListHeader) mLayoutInflater.inflate( + R.layout.widgets_list_row_header, parent, false); + + return new WidgetsListSearchHeaderHolder(header); + } + + @Override + public void bindViewHolder(WidgetsListSearchHeaderHolder viewHolder, + WidgetsListSearchHeaderEntry data) { + WidgetsListHeader widgetsListHeader = viewHolder.mWidgetsListHeader; + widgetsListHeader.applyFromItemInfoWithIcon(data); + widgetsListHeader.setExpanded(data.isWidgetListShown()); + widgetsListHeader.setOnExpandChangeListener(isExpanded -> + mOnHeaderClickListener.onHeaderClicked(isExpanded, + new PackageUserKey(data.mPkgItem.packageName, data.mPkgItem.user))); + } +} diff --git a/src/com/android/launcher3/widget/picker/search/SearchModeListener.java b/src/com/android/launcher3/widget/picker/search/SearchModeListener.java new file mode 100644 index 0000000000..cee7d6735a --- /dev/null +++ b/src/com/android/launcher3/widget/picker/search/SearchModeListener.java @@ -0,0 +1,40 @@ +/* + * 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.search; + +import com.android.launcher3.widget.model.WidgetsListBaseEntry; + +import java.util.List; + +/** + * A listener to help with widgets picker search. + */ +public interface SearchModeListener { + /** + * Notifies the subscriber when user enters widget picker search mode. + */ + void enterSearchMode(); + + /** + * Notifies the subscriber when user exits widget picker search mode. + */ + void exitSearchMode(); + + /** + * Notifies the subscriber with search results. + */ + void onSearchResults(List entries); +} diff --git a/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java b/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java index 99114955aa..5222e8ec59 100644 --- a/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java +++ b/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java @@ -16,12 +16,19 @@ package com.android.launcher3.widget.picker.search; -import com.android.launcher3.widget.model.WidgetsListBaseEntry; +import static com.android.launcher3.search.StringMatcherUtility.matches; + +import com.android.launcher3.model.WidgetItem; +import com.android.launcher3.search.StringMatcherUtility.StringMatcher; +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.model.WidgetsListSearchHeaderEntry; -import java.text.Collator; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; +import java.util.stream.Collectors; /** * Implementation of {@link WidgetsPickerSearchPipeline} that performs search by prefix matching on @@ -37,52 +44,29 @@ public final class SimpleWidgetsSearchPipeline implements WidgetsPickerSearchPip @Override public void query(String input, Consumer> callback) { - StringMatcher matcher = StringMatcher.getInstance(); ArrayList results = new ArrayList<>(); - // TODO(b/157286785): Filter entries based on query prefix matching on widget labels also. - for (WidgetsListBaseEntry e : mAllEntries) { - if (matcher.matches(input, e.mPkgItem.title.toString())) { - results.add(e); - } - } + mAllEntries.stream().filter(entry -> entry instanceof WidgetsListHeaderEntry) + .forEach(headerEntry -> { + List matchedWidgetItems = filterWidgetItems( + input, headerEntry.mPkgItem.title.toString(), headerEntry.mWidgets); + if (matchedWidgetItems.size() > 0) { + results.add(new WidgetsListSearchHeaderEntry(headerEntry.mPkgItem, + headerEntry.mTitleSectionName, matchedWidgetItems)); + results.add(new WidgetsListContentEntry(headerEntry.mPkgItem, + headerEntry.mTitleSectionName, matchedWidgetItems)); + } + }); callback.accept(results); } - /** - * Performs locale sensitive string comparison using {@link Collator}. - */ - public static class StringMatcher { - - private static final char MAX_UNICODE = '\uFFFF'; - - private final Collator mCollator; - - StringMatcher() { - mCollator = Collator.getInstance(); - mCollator.setStrength(Collator.PRIMARY); - mCollator.setDecomposition(Collator.CANONICAL_DECOMPOSITION); - } - - /** - * Returns true if {@param query} is a prefix of {@param target}. - */ - public boolean matches(String query, String target) { - switch (mCollator.compare(query, target)) { - case 0: - return true; - case -1: - // The target string can contain a modifier which would make it larger than - // the query string (even though the length is same). If the query becomes - // larger after appending a unicode character, it was originally a prefix of - // the target string and hence should match. - return mCollator.compare(query + MAX_UNICODE, target) > -1; - default: - return false; - } - } - - public static StringMatcher getInstance() { - return new StringMatcher(); + private List filterWidgetItems(String query, String packageTitle, + List items) { + StringMatcher matcher = StringMatcher.getInstance(); + if (matches(query, packageTitle, matcher)) { + return items; } + return items.stream() + .filter(item -> matches(query, item.label, matcher)) + .collect(Collectors.toList()); } } diff --git a/src/com/android/launcher3/widget/picker/search/WidgetsSearchBar.java b/src/com/android/launcher3/widget/picker/search/WidgetsSearchBar.java new file mode 100644 index 0000000000..d8e9733ce5 --- /dev/null +++ b/src/com/android/launcher3/widget/picker/search/WidgetsSearchBar.java @@ -0,0 +1,79 @@ +/* + * 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.search; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.launcher3.R; +import com.android.launcher3.search.SearchAlgorithm; +import com.android.launcher3.widget.model.WidgetsListBaseEntry; + +import java.util.List; + +/** + * View for a search bar with an edit text with a cancel button. + */ +public class WidgetsSearchBar extends LinearLayout { + private WidgetsSearchBarController mController; + private EditText mEditText; + private ImageButton mCancelButton; + + public WidgetsSearchBar(Context context) { + this(context, null, 0); + } + + public WidgetsSearchBar(@NonNull Context context, + @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public WidgetsSearchBar(@NonNull Context context, @Nullable AttributeSet attrs, + int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + /** + * Attaches a controller to the search bar which interacts with {@code searchModeListener}. + */ + public void initialize(List allWidgets, + SearchModeListener searchModeListener) { + SearchAlgorithm algo = + new SimpleWidgetsSearchAlgorithm(new SimpleWidgetsSearchPipeline(allWidgets)); + mController = new WidgetsSearchBarController( + algo, mEditText, mCancelButton, searchModeListener); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mEditText = findViewById(R.id.widgets_search_bar_edit_text); + mCancelButton = findViewById(R.id.widgets_search_cancel_button); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mController.onDestroy(); + } +} diff --git a/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarController.java b/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarController.java new file mode 100644 index 0000000000..6c374842fd --- /dev/null +++ b/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarController.java @@ -0,0 +1,111 @@ +/* + * 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.search; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import android.text.Editable; +import android.text.TextWatcher; +import android.util.Log; +import android.widget.EditText; +import android.widget.ImageButton; + +import com.android.launcher3.search.SearchAlgorithm; +import com.android.launcher3.search.SearchCallback; +import com.android.launcher3.widget.model.WidgetsListBaseEntry; + +import java.util.ArrayList; + +/** + * Controller for a search bar with an edit text and a cancel button. + */ +public class WidgetsSearchBarController implements TextWatcher, + SearchCallback { + private static final String TAG = "WidgetsSearchBarController"; + private static final boolean DEBUG = false; + + protected SearchAlgorithm mSearchAlgorithm; + protected EditText mInput; + protected ImageButton mCancelButton; + protected SearchModeListener mSearchModeListener; + protected String mQuery; + + public WidgetsSearchBarController( + SearchAlgorithm algo, EditText editText, ImageButton cancelButton, + SearchModeListener searchModeListener) { + mSearchAlgorithm = algo; + mInput = editText; + mInput.addTextChangedListener(this); + mCancelButton = cancelButton; + mCancelButton.setOnClickListener(v -> clearSearchResult()); + mSearchModeListener = searchModeListener; + } + + @Override + public void afterTextChanged(final Editable s) { + mQuery = s.toString(); + if (mQuery.isEmpty()) { + mSearchAlgorithm.cancel(/* interruptActiveRequests= */ true); + mSearchModeListener.exitSearchMode(); + mCancelButton.setVisibility(GONE); + } else { + mSearchAlgorithm.cancel(/* interruptActiveRequests= */ false); + mSearchModeListener.enterSearchMode(); + mSearchAlgorithm.doSearch(mQuery, this); + mCancelButton.setVisibility(VISIBLE); + } + } + + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + // Do nothing. + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + // Do nothing. + } + + @Override + public void onSearchResult(String query, ArrayList items) { + if (DEBUG) { + Log.d(TAG, "onSearchResult query: " + query + " items: " + items); + } + mSearchModeListener.onSearchResults(items); + } + + @Override + public void onAppendSearchResult(String query, ArrayList items) { + // Not needed. + } + + @Override + public void clearSearchResult() { + mSearchAlgorithm.cancel(/* interruptActiveRequests= */ true); + mInput.getText().clear(); + mInput.clearFocus(); + mSearchModeListener.exitSearchMode(); + } + + /** + * Cleans up after search is no longer needed. + */ + public void onDestroy() { + mSearchAlgorithm.destroy(); + } +} diff --git a/tests/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithmTest.java b/tests/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithmTest.java deleted file mode 100644 index 39709a9539..0000000000 --- a/tests/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithmTest.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (C) 2016 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.allapps.search; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import android.content.ComponentName; - -import androidx.test.filters.SmallTest; -import androidx.test.runner.AndroidJUnit4; - -import com.android.launcher3.model.data.AppInfo; - -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * Unit tests for {@link DefaultAppSearchAlgorithm} - */ -@SmallTest -@RunWith(AndroidJUnit4.class) -public class DefaultAppSearchAlgorithmTest { - private static final DefaultAppSearchAlgorithm.StringMatcher MATCHER = - DefaultAppSearchAlgorithm.StringMatcher.getInstance(); - - @Test - public void testMatches() { - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("white cow"), "cow", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("whiteCow"), "cow", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("whiteCOW"), "cow", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("whitecowCOW"), "cow", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("white2cow"), "cow", MATCHER)); - - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("whitecow"), "cow", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("whitEcow"), "cow", MATCHER)); - - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("whitecowCow"), "cow", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("whitecow cow"), "cow", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("whitecowcow"), "cow", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("whit ecowcow"), "cow", MATCHER)); - - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("cats&dogs"), "dog", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("cats&Dogs"), "dog", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("cats&Dogs"), "&", MATCHER)); - - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("2+43"), "43", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("2+43"), "3", MATCHER)); - - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("Q"), "q", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo(" Q"), "q", MATCHER)); - - // match lower case words - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("elephant"), "e", MATCHER)); - - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("电子邮件"), "电", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("电子邮件"), "电子", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("电子邮件"), "子", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("电子邮件"), "邮件", MATCHER)); - - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("Bot"), "ba", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("bot"), "ba", MATCHER)); - } - - @Test - public void testMatchesVN() { - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("다운로드"), "다", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("드라이브"), "드", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("다운로드 드라이브"), "ㄷ", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("운로 드라이브"), "ㄷ", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("abc"), "åbç", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("Alpha"), "ål", MATCHER)); - - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("다운로드 드라이브"), "ㄷㄷ", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("로드라이브"), "ㄷ", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("abc"), "åç", MATCHER)); - } - - private AppInfo getInfo(String title) { - AppInfo info = new AppInfo(); - info.title = title; - info.componentName = new ComponentName("Test", title); - return info; - } -} diff --git a/tests/src/com/android/launcher3/search/StringMatcherUtilityTest.java b/tests/src/com/android/launcher3/search/StringMatcherUtilityTest.java new file mode 100644 index 0000000000..413f404406 --- /dev/null +++ b/tests/src/com/android/launcher3/search/StringMatcherUtilityTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2016 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.search; + +import static com.android.launcher3.search.StringMatcherUtility.matches; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import com.android.launcher3.search.StringMatcherUtility.StringMatcher; + +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Unit tests for {@link StringMatcherUtility} + */ +@SmallTest +@RunWith(AndroidJUnit4.class) +public class StringMatcherUtilityTest { + private static final StringMatcher MATCHER = + StringMatcher.getInstance(); + + @Test + public void testMatches() { + assertTrue(matches("white ", "white cow", MATCHER)); + assertTrue(matches("white c", "white cow", MATCHER)); + assertTrue(matches("cow", "white cow", MATCHER)); + assertTrue(matches("cow", "whiteCow", MATCHER)); + assertTrue(matches("cow", "whiteCOW", MATCHER)); + assertTrue(matches("cow", "whitecowCOW", MATCHER)); + assertTrue(matches("cow", "white2cow", MATCHER)); + + assertFalse(matches("cow", "whitecow", MATCHER)); + assertFalse(matches("cow", "whitEcow", MATCHER)); + + assertTrue(matches("cow", "whitecowCow", MATCHER)); + assertTrue(matches("cow", "whitecow cow", MATCHER)); + assertFalse(matches("cow", "whitecowcow", MATCHER)); + assertFalse(matches("cow", "whit ecowcow", MATCHER)); + + assertTrue(matches("dog", "cats&dogs", MATCHER)); + assertTrue(matches("dog", "cats&Dogs", MATCHER)); + assertTrue(matches("&", "cats&Dogs", MATCHER)); + + assertTrue(matches("43", "2+43", MATCHER)); + assertFalse(matches("3", "2+43", MATCHER)); + + assertTrue(matches("q", "Q", MATCHER)); + assertTrue(matches("q", " Q", MATCHER)); + + // match lower case words + assertTrue(matches("e", "elephant", MATCHER)); + assertTrue(matches("eL", "Elephant", MATCHER)); + + assertTrue(matches("电", "电子邮件", MATCHER)); + assertTrue(matches("电子", "电子邮件", MATCHER)); + assertTrue(matches("子", "电子邮件", MATCHER)); + assertTrue(matches("邮件", "电子邮件", MATCHER)); + + assertFalse(matches("ba", "Bot", MATCHER)); + assertFalse(matches("ba", "bot", MATCHER)); + assertFalse(matches("phant", "elephant", MATCHER)); + assertFalse(matches("elephants", "elephant", MATCHER)); + } + + @Test + public void testMatchesVN() { + assertTrue(matches("다", "다운로드", MATCHER)); + assertTrue(matches("드", "드라이브", MATCHER)); + assertTrue(matches("ㄷ", "다운로드 드라이브", MATCHER)); + assertTrue(matches("ㄷ", "운로 드라이브", MATCHER)); + assertTrue(matches("åbç", "abc", MATCHER)); + assertTrue(matches("ål", "Alpha", MATCHER)); + + assertFalse(matches("ㄷㄷ", "다운로드 드라이브", MATCHER)); + assertFalse(matches("ㄷ", "로드라이브", MATCHER)); + assertFalse(matches("åç", "abc", MATCHER)); + } +}