Merge changes from topic "SEARCH_INTEGRATE" into sc-dev

* changes:
  Add a WidgetsSearchBar(Launcher3) and a WidgetsSearchController.
  Have a recycler view to show search results.
This commit is contained in:
Alina Zaidi
2021-03-15 13:24:20 +00:00
committed by Android (Google) Code Review
35 changed files with 1421 additions and 406 deletions
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?android:attr/textColorTertiary"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12 19,6.41z"/>
</vector>
+8
View File
@@ -51,5 +51,13 @@
android:layout_alignParentEnd="true"
android:layout_alignParentTop="true"
android:layout_marginEnd="@dimen/fastscroll_end_margin" />
<com.android.launcher3.widget.picker.WidgetsRecyclerView
android:id="@+id/search_widgets_list_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:clipToPadding="false" />
</com.android.launcher3.views.TopRoundedCornerView>
</com.android.launcher3.widget.picker.WidgetsFullSheet>
@@ -34,16 +34,5 @@
android:textSize="24sp"
android:layout_marginTop="16dp"
android:text="@string/widget_button_text"/>
<!-- Disable the search bar because it has not been implemented. -->
<EditText
android:id="@+id/widgets_search_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="16dp"
android:background="@drawable/bg_widgets_searchbox"
android:drawablePadding="8dp"
android:drawableStart="@drawable/ic_allapps_search"
android:hint="@string/widgets_full_sheet_search_bar_hint"
android:padding="12dp" />
<include layout="@layout/widgets_search_bar"/>
</LinearLayout>
+2
View File
@@ -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" />
</LinearLayout>
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<com.android.launcher3.widget.picker.search.WidgetsSearchBar
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widgets_search_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="16dp"
android:background="@drawable/bg_widgets_searchbox"
android:padding="12dp"
android:visibility="gone">
<EditText
android:id="@+id/widgets_search_bar_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="8dp"
android:drawableStart="@drawable/ic_allapps_search"
android:background="@null"
android:hint="@string/widgets_full_sheet_search_bar_hint"
android:maxLines="1"
android:layout_weight="1"
android:inputType="text"/>
<ImageButton
android:id="@+id/widgets_search_cancel_button"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:src="@drawable/ic_gm_close_24"
android:background="?android:selectableItemBackground"
android:layout_gravity="center"
android:visibility="gone"/>
</com.android.launcher3.widget.picker.search.WidgetsSearchBar>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2020 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.
-->
<resources>
<item type="id" name="view_type_widgets_list" />
<item type="id" name="view_type_widgets_header" />
<item type="id" name="view_type_widgets_search_header" />
</resources>
@@ -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<WidgetsListBaseEntry> currentList = new ArrayList<>(
List.of(mHeaderA, mHeaderB, mContentE));
// GIVEN the new list has one of the headers widgets list modified.
List<WidgetsListBaseEntry> 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) {
@@ -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<WidgetsListBaseEntry> 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.
*
@@ -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<TestActivity> 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;
}
}
}
@@ -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<TestActivity> 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<WidgetItem> generateWidgetItems(String packageName, int numOfWidgets) {
ShadowPackageManager packageManager = shadowOf(mContext.getPackageManager());
ArrayList<WidgetItem> 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;
}
}
@@ -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,
@@ -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<WidgetsListBaseEntry> 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<WidgetsListBaseEntry> 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(), "");
}
}
@@ -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<AppInfo> 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);
}
}
@@ -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<AdapterItem> {
() -> 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;
}
}
@@ -31,4 +31,9 @@ public interface SearchAlgorithm<T> {
* Cancels any active request.
*/
void cancel(boolean interruptActiveRequests);
/**
* Cleans up after search is no longer needed.
*/
default void destroy() {};
}
@@ -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;
}
}
@@ -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<WidgetItem> mWidgets;
public WidgetsListBaseEntry(PackageItemInfo pkgItem, String titleSectionName,
List<WidgetItem> 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;
}
@@ -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<WidgetItem> mWidgets;
public WidgetsListContentEntry(PackageItemInfo pkgItem, String titleSectionName,
List<WidgetItem> 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);
}
}
@@ -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<WidgetItem> items) {
super(pkgItem, titleSectionName);
List<WidgetItem> 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);
}
}
@@ -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<WidgetItem> 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);
}
}
@@ -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);
}
@@ -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());
}
/**
@@ -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) {
@@ -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<WidgetsListBaseEntry> 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) {
@@ -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<ViewHolder> 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<ViewHolderBinder> mViewHolderBinders = new SparseArray<>();
@@ -73,11 +75,13 @@ public class WidgetsListAdapter extends Adapter<ViewHolder> implements OnHeaderC
private List<WidgetsListBaseEntry> mAllEntries = new ArrayList<>();
private ArrayList<WidgetsListBaseEntry> mVisibleEntries = new ArrayList<>();
@Nullable private String mWidgetsContentVisiblePackage = null;
@Nullable private PackageUserKey mWidgetsContentVisiblePackageUserKey = null;
private Predicate<WidgetsListBaseEntry> 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<WidgetsListBaseEntry> mFilter = null;
public WidgetsListAdapter(Context context, LayoutInflater layoutInflater,
@@ -87,8 +91,14 @@ public class WidgetsListAdapter extends Adapter<ViewHolder> 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<WidgetsListBaseEntry> filter) {
@@ -132,18 +142,30 @@ public class WidgetsListAdapter extends Adapter<ViewHolder> implements OnHeaderC
return mVisibleEntries.get(pos).mTitleSectionName;
}
/** Updates the widget list. */
/** Updates the widget list based on {@code tempEntries}. */
public void setWidgets(List<WidgetsListBaseEntry> tempEntries) {
mAllEntries = tempEntries.stream().sorted(mRowComparator)
.collect(Collectors.toList());
updateVisibleEntries();
}
/** Updates the widget list based on {@code searchResults}. */
public void setWidgetsOnSearch(List<WidgetsListBaseEntry> 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<WidgetsListBaseEntry> newVisibleEntries = mAllEntries.stream()
@@ -194,17 +216,19 @@ public class WidgetsListAdapter extends Adapter<ViewHolder> 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();
}
}
@@ -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) {
@@ -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)
));
}
}
@@ -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;
}
}
@@ -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<WidgetsListSearchHeaderEntry, WidgetsListSearchHeaderHolder> {
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)));
}
}
@@ -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<WidgetsListBaseEntry> entries);
}
@@ -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<List<WidgetsListBaseEntry>> callback) {
StringMatcher matcher = StringMatcher.getInstance();
ArrayList<WidgetsListBaseEntry> 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<WidgetItem> 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<WidgetItem> filterWidgetItems(String query, String packageTitle,
List<WidgetItem> 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());
}
}
@@ -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<WidgetsListBaseEntry> allWidgets,
SearchModeListener searchModeListener) {
SearchAlgorithm<WidgetsListBaseEntry> 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();
}
}
@@ -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<WidgetsListBaseEntry> {
private static final String TAG = "WidgetsSearchBarController";
private static final boolean DEBUG = false;
protected SearchAlgorithm<WidgetsListBaseEntry> mSearchAlgorithm;
protected EditText mInput;
protected ImageButton mCancelButton;
protected SearchModeListener mSearchModeListener;
protected String mQuery;
public WidgetsSearchBarController(
SearchAlgorithm<WidgetsListBaseEntry> 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<WidgetsListBaseEntry> items) {
if (DEBUG) {
Log.d(TAG, "onSearchResult query: " + query + " items: " + items);
}
mSearchModeListener.onSearchResults(items);
}
@Override
public void onAppendSearchResult(String query, ArrayList<WidgetsListBaseEntry> 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();
}
}
@@ -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;
}
}
@@ -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));
}
}