Add a WidgetsSearchBar(Launcher3) and a WidgetsSearchController.

- Make WidgetsSearchBar in Launcher3 initialize WidgetsSearchController with SimpleWidgetsSearchPipeline
- Modify SimpleWidgetsSearchPipeline to filter widgets entries on widgets/shortcut labels also.

Test: Tested prototype locally. Also added robolectric test.
Bug: b/157286785
Change-Id: I65f5fa0240ffb6d22023167e4e86d94d83bbd9f7
This commit is contained in:
Alina Zaidi 2021-03-09 21:23:35 +00:00
parent 334e65935b
commit d07acba048
18 changed files with 733 additions and 348 deletions

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>

View File

@ -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>

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>

View File

@ -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,

View File

@ -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(), "");
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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;
}
}

View File

@ -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());
}
/**

View File

@ -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,13 +26,9 @@ 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
@ -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);
}
}

View File

@ -17,25 +17,21 @@ 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;
/** An information holder for an app which has widgets or/and shortcuts. */
public final class WidgetsListHeaderEntry extends WidgetsListBaseEntry {
public final int widgetsCount;
public final int shortcutsCount;
public final List<WidgetItem> mWidgets;
private boolean mIsWidgetListShown = false;
private boolean mHasEntryUpdated = false;
public WidgetsListHeaderEntry(PackageItemInfo pkgItem, String titleSectionName,
List<WidgetItem> items) {
super(pkgItem, titleSectionName);
mWidgets = items.stream().sorted(new WidgetItemComparator()).collect(Collectors.toList());
super(pkgItem, titleSectionName, items);
widgetsCount = (int) items.stream().filter(item -> item.widgetInfo != null).count();
shortcutsCount = Math.max(0, items.size() - widgetsCount);
}

View File

@ -17,23 +17,18 @@ 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;
/** An information holder for an app which has widgets or/and shortcuts, to be shown in search. */
public final class WidgetsListSearchHeaderEntry extends WidgetsListBaseEntry {
public final List<WidgetItem> mWidgets;
private boolean mIsWidgetListShown = false;
private boolean mHasEntryUpdated = false;
public WidgetsListSearchHeaderEntry(PackageItemInfo pkgItem, String titleSectionName,
List<WidgetItem> items) {
super(pkgItem, titleSectionName);
mWidgets = items.stream().sorted(new WidgetItemComparator()).collect(Collectors.toList());
super(pkgItem, titleSectionName, items);
}
/** Sets if the widgets list associated with this header is shown. */

View File

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

View File

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

View File

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

View File

@ -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;
}
}

View File

@ -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));
}
}