diff --git a/res/drawable/ic_gm_close_24.xml b/res/drawable/ic_gm_close_24.xml new file mode 100644 index 0000000000..2c9c932fa3 --- /dev/null +++ b/res/drawable/ic_gm_close_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/res/layout/widgets_full_sheet_search_and_recommendations.xml b/res/layout/widgets_full_sheet_search_and_recommendations.xml index 9a6f922d7d..6182255dc6 100644 --- a/res/layout/widgets_full_sheet_search_and_recommendations.xml +++ b/res/layout/widgets_full_sheet_search_and_recommendations.xml @@ -34,16 +34,5 @@ android:textSize="24sp" android:layout_marginTop="16dp" android:text="@string/widget_button_text"/> - - + diff --git a/res/layout/widgets_search_bar.xml b/res/layout/widgets_search_bar.xml new file mode 100644 index 0000000000..252637d9cf --- /dev/null +++ b/res/layout/widgets_search_bar.xml @@ -0,0 +1,33 @@ + + + + + + + \ No newline at end of file diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipelineTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipelineTest.java index 8aebf12b89..17ededdb37 100644 --- a/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipelineTest.java +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipelineTest.java @@ -19,8 +19,6 @@ package com.android.launcher3.widget.picker.search; import static android.os.Looper.getMainLooper; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.robolectric.Shadows.shadowOf; @@ -40,6 +38,7 @@ import com.android.launcher3.model.data.PackageItemInfo; import com.android.launcher3.widget.LauncherAppWidgetProviderInfo; import com.android.launcher3.widget.model.WidgetsListContentEntry; import com.android.launcher3.widget.model.WidgetsListHeaderEntry; +import com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry; import org.junit.Before; import org.junit.Test; @@ -56,9 +55,6 @@ import java.util.List; @RunWith(RobolectricTestRunner.class) public class SimpleWidgetsSearchPipelineTest { - private static final SimpleWidgetsSearchPipeline.StringMatcher MATCHER = - SimpleWidgetsSearchPipeline.StringMatcher.getInstance(); - @Mock private IconCache mIconCache; private InvariantDeviceProfile mTestProfile; @@ -73,9 +69,10 @@ public class SimpleWidgetsSearchPipelineTest { @Before public void setUp() { MockitoAnnotations.initMocks(this); - doAnswer(invocation -> ((ComponentWithLabel) invocation.getArgument(0)) - .getComponent().getPackageName()) - .when(mIconCache).getTitleNoCache(any()); + doAnswer(invocation -> { + ComponentWithLabel componentWithLabel = (ComponentWithLabel) invocation.getArgument(0); + return componentWithLabel.getComponent().getShortClassName(); + }).when(mIconCache).getTitleNoCache(any()); mTestProfile = new InvariantDeviceProfile(); mTestProfile.numRows = 5; mTestProfile.numColumns = 5; @@ -85,54 +82,60 @@ public class SimpleWidgetsSearchPipelineTest { createWidgetsHeaderEntry("com.example.android.Calendar", "Calendar", 2); mCalendarContentEntry = createWidgetsContentEntry("com.example.android.Calendar", "Calendar", 2); - mCameraHeaderEntry = createWidgetsHeaderEntry("com.example.android.Camera", "Camera", 5); - mCameraContentEntry = createWidgetsContentEntry("com.example.android.Camera", "Camera", 5); + mCameraHeaderEntry = createWidgetsHeaderEntry("com.example.android.Camera", "Camera", 11); + mCameraContentEntry = createWidgetsContentEntry("com.example.android.Camera", "Camera", 11); mClockHeaderEntry = createWidgetsHeaderEntry("com.example.android.Clock", "Clock", 3); mClockContentEntry = createWidgetsContentEntry("com.example.android.Clock", "Clock", 3); } @Test - public void query_shouldInformCallbackWithResultsMatchedOnAppName() { + public void query_shouldMatchOnAppName() { SimpleWidgetsSearchPipeline pipeline = new SimpleWidgetsSearchPipeline( List.of(mCalendarHeaderEntry, mCalendarContentEntry, mCameraHeaderEntry, mCameraContentEntry, mClockHeaderEntry, mClockContentEntry)); pipeline.query("Ca", results -> - assertEquals(results, List.of(mCalendarHeaderEntry, mCalendarContentEntry, - mCameraHeaderEntry, mCameraContentEntry))); + assertEquals(results, + List.of( + new WidgetsListSearchHeaderEntry( + mCalendarHeaderEntry.mPkgItem, + mCalendarHeaderEntry.mTitleSectionName, + mCalendarHeaderEntry.mWidgets), + mCalendarContentEntry, + new WidgetsListSearchHeaderEntry( + mCameraHeaderEntry.mPkgItem, + mCameraHeaderEntry.mTitleSectionName, + mCameraHeaderEntry.mWidgets), + mCameraContentEntry))); shadowOf(getMainLooper()).idle(); } @Test - public void testMatches() { - assertTrue(MATCHER.matches("q", "Q")); - assertTrue(MATCHER.matches("q", " Q")); - assertTrue(MATCHER.matches("e", "elephant")); - assertTrue(MATCHER.matches("eL", "Elephant")); - assertTrue(MATCHER.matches("elephant ", "elephant")); - assertTrue(MATCHER.matches("whitec", "white cow")); - assertTrue(MATCHER.matches("white c", "white cow")); - assertTrue(MATCHER.matches("white ", "white cow")); - assertTrue(MATCHER.matches("white c", "white cow")); - assertTrue(MATCHER.matches("电", "电子邮件")); - assertTrue(MATCHER.matches("电子", "电子邮件")); - assertTrue(MATCHER.matches("다", "다운로드")); - assertTrue(MATCHER.matches("드", "드라이브")); - assertTrue(MATCHER.matches("åbç", "abc")); - assertTrue(MATCHER.matches("ål", "Alpha")); + public void query_shouldMatchOnWidgetLabel() { + SimpleWidgetsSearchPipeline pipeline = new SimpleWidgetsSearchPipeline( + List.of(mCalendarHeaderEntry, mCalendarContentEntry, mCameraHeaderEntry, + mCameraContentEntry)); - assertFalse(MATCHER.matches("phant", "elephant")); - assertFalse(MATCHER.matches("elephants", "elephant")); - assertFalse(MATCHER.matches("cow", "white cow")); - assertFalse(MATCHER.matches("cow", "whiteCow")); - assertFalse(MATCHER.matches("dog", "cats&Dogs")); - assertFalse(MATCHER.matches("ba", "Bot")); - assertFalse(MATCHER.matches("ba", "bot")); - assertFalse(MATCHER.matches("子", "电子邮件")); - assertFalse(MATCHER.matches("邮件", "电子邮件")); - assertFalse(MATCHER.matches("ㄷ", "다운로드 드라이브")); - assertFalse(MATCHER.matches("ㄷㄷ", "다운로드 드라이브")); - assertFalse(MATCHER.matches("åç", "abc")); + pipeline.query("Widget1", results -> + assertEquals(results, + List.of( + new WidgetsListSearchHeaderEntry( + mCalendarHeaderEntry.mPkgItem, + mCalendarHeaderEntry.mTitleSectionName, + mCalendarHeaderEntry.mWidgets.subList(1, 2)), + new WidgetsListContentEntry( + mCalendarHeaderEntry.mPkgItem, + mCalendarHeaderEntry.mTitleSectionName, + mCalendarHeaderEntry.mWidgets.subList(1, 2)), + new WidgetsListSearchHeaderEntry( + mCameraHeaderEntry.mPkgItem, + mCameraHeaderEntry.mTitleSectionName, + mCameraHeaderEntry.mWidgets.subList(1, 3)), + new WidgetsListContentEntry( + mCameraHeaderEntry.mPkgItem, + mCameraHeaderEntry.mTitleSectionName, + mCameraHeaderEntry.mWidgets.subList(1, 3))))); + shadowOf(getMainLooper()).idle(); } private WidgetsListHeaderEntry createWidgetsHeaderEntry(String packageName, String appName, diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarControllerTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarControllerTest.java new file mode 100644 index 0000000000..7fc965051d --- /dev/null +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarControllerTest.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.widget.picker.search; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import android.content.Context; +import android.view.View; +import android.widget.EditText; +import android.widget.ImageButton; + +import com.android.launcher3.search.SearchAlgorithm; +import com.android.launcher3.widget.model.WidgetsListBaseEntry; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.ArrayList; + +@RunWith(RobolectricTestRunner.class) +public class WidgetsSearchBarControllerTest { + + private WidgetsSearchBarController mController; + private Context mContext; + private EditText mEditText; + private ImageButton mCancelButton; + @Mock + private SearchModeListener mSearchModeListener; + @Mock + private SearchAlgorithm mSearchAlgorithm; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = RuntimeEnvironment.application; + mEditText = new EditText(mContext); + mCancelButton = new ImageButton(mContext); + mController = new WidgetsSearchBarController( + mSearchAlgorithm, mEditText, mCancelButton, mSearchModeListener); + } + + @Test + public void onSearchResult_shouldInformSearchModeListener() { + ArrayList entries = new ArrayList<>(); + mController.onSearchResult("abc", entries); + + verify(mSearchModeListener).onSearchResults(entries); + } + + @Test + public void afterTextChanged_shouldInformSearchModeListenerToEnterSearch() { + mEditText.setText("abc"); + + verify(mSearchModeListener).enterSearchMode(); + verifyNoMoreInteractions(mSearchModeListener); + } + + @Test + public void afterTextChanged_shouldDoSearch() { + mEditText.setText("abc"); + + verify(mSearchAlgorithm).doSearch(eq("abc"), any()); + } + + @Test + public void afterTextChanged_shouldShowCancelButton() { + mEditText.setText("abc"); + + assertEquals(mCancelButton.getVisibility(), View.VISIBLE); + } + + @Test + public void afterTextChanged_empty_shouldInformSearchModeListenerToExitSearch() { + mEditText.setText(""); + + verify(mSearchModeListener).exitSearchMode(); + verifyNoMoreInteractions(mSearchModeListener); + } + + @Test + public void afterTextChanged_empty_shouldCancelSearch() { + mEditText.setText(""); + + verify(mSearchAlgorithm).cancel(true); + verifyNoMoreInteractions(mSearchAlgorithm); + } + + @Test + public void afterTextChanged_empty_shouldHideCancelButton() { + mEditText.setText(""); + + assertEquals(mCancelButton.getVisibility(), View.GONE); + } + + @Test + public void cancelSearch_shouldInformSearchModeListenerToExitSearch() { + mCancelButton.performClick(); + + verify(mSearchModeListener).exitSearchMode(); + verifyNoMoreInteractions(mSearchModeListener); + } + + @Test + public void cancelSearch_shouldCancelSearch() { + mCancelButton.performClick(); + + verify(mSearchAlgorithm).cancel(true); + verifyNoMoreInteractions(mSearchAlgorithm); + } + + @Test + public void cancelSearch_shouldClearSearchBar() { + mCancelButton.performClick(); + + assertEquals(mEditText.getText().toString(), ""); + } +} diff --git a/src/com/android/launcher3/allapps/search/AppsSearchPipeline.java b/src/com/android/launcher3/allapps/search/AppsSearchPipeline.java index f9fb22ece7..34895ed5f5 100644 --- a/src/com/android/launcher3/allapps/search/AppsSearchPipeline.java +++ b/src/com/android/launcher3/allapps/search/AppsSearchPipeline.java @@ -25,6 +25,7 @@ import com.android.launcher3.model.AllAppsList; import com.android.launcher3.model.BaseModelUpdateTask; import com.android.launcher3.model.BgDataModel; import com.android.launcher3.model.data.AppInfo; +import com.android.launcher3.search.StringMatcherUtility; import java.util.ArrayList; import java.util.List; @@ -67,10 +68,10 @@ public class AppsSearchPipeline implements SearchPipeline { // apps that don't match all of the words in the query. final String queryTextLower = query.toLowerCase(); final ArrayList result = new ArrayList<>(); - DefaultAppSearchAlgorithm.StringMatcher matcher = - DefaultAppSearchAlgorithm.StringMatcher.getInstance(); + StringMatcherUtility.StringMatcher matcher = + StringMatcherUtility.StringMatcher.getInstance(); for (AppInfo info : apps) { - if (DefaultAppSearchAlgorithm.matches(info, queryTextLower, matcher)) { + if (StringMatcherUtility.matches(queryTextLower, info.title.toString(), matcher)) { result.add(info); } } diff --git a/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithm.java b/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithm.java index 4e213b0a1b..a386ef8ee2 100644 --- a/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithm.java +++ b/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithm.java @@ -20,12 +20,9 @@ import android.os.Handler; import com.android.launcher3.LauncherAppState; import com.android.launcher3.allapps.AllAppsGridAdapter.AdapterItem; -import com.android.launcher3.model.data.AppInfo; import com.android.launcher3.search.SearchAlgorithm; import com.android.launcher3.search.SearchCallback; -import java.text.Collator; - /** * The default search implementation. */ @@ -54,132 +51,4 @@ public class DefaultAppSearchAlgorithm implements SearchAlgorithm { () -> callback.onSearchResult(query, results)), null); } - - public static boolean matches(AppInfo info, String query, StringMatcher matcher) { - int queryLength = query.length(); - - String title = info.title.toString(); - int titleLength = title.length(); - - if (titleLength < queryLength || queryLength <= 0) { - return false; - } - - if (requestSimpleFuzzySearch(query)) { - return title.toLowerCase().contains(query); - } - - int lastType; - int thisType = Character.UNASSIGNED; - int nextType = Character.getType(title.codePointAt(0)); - - int end = titleLength - queryLength; - for (int i = 0; i <= end; i++) { - lastType = thisType; - thisType = nextType; - nextType = i < (titleLength - 1) ? - Character.getType(title.codePointAt(i + 1)) : Character.UNASSIGNED; - if (isBreak(thisType, lastType, nextType) && - matcher.matches(query, title.substring(i, i + queryLength))) { - return true; - } - } - return false; - } - - /** - * Returns true if the current point should be a break point. Following cases - * are considered as break points: - * 1) Any non space character after a space character - * 2) Any digit after a non-digit character - * 3) Any capital character after a digit or small character - * 4) Any capital character before a small character - */ - private static boolean isBreak(int thisType, int prevType, int nextType) { - switch (prevType) { - case Character.UNASSIGNED: - case Character.SPACE_SEPARATOR: - case Character.LINE_SEPARATOR: - case Character.PARAGRAPH_SEPARATOR: - return true; - } - switch (thisType) { - case Character.UPPERCASE_LETTER: - if (nextType == Character.UPPERCASE_LETTER) { - return true; - } - // Follow through - case Character.TITLECASE_LETTER: - // Break point if previous was not a upper case - return prevType != Character.UPPERCASE_LETTER; - case Character.LOWERCASE_LETTER: - // Break point if previous was not a letter. - return prevType > Character.OTHER_LETTER || prevType <= Character.UNASSIGNED; - case Character.DECIMAL_DIGIT_NUMBER: - case Character.LETTER_NUMBER: - case Character.OTHER_NUMBER: - // Break point if previous was not a number - return !(prevType == Character.DECIMAL_DIGIT_NUMBER - || prevType == Character.LETTER_NUMBER - || prevType == Character.OTHER_NUMBER); - case Character.MATH_SYMBOL: - case Character.CURRENCY_SYMBOL: - case Character.OTHER_PUNCTUATION: - case Character.DASH_PUNCTUATION: - // Always a break point for a symbol - return true; - default: - return false; - } - } - - public static class StringMatcher { - - private static final char MAX_UNICODE = '\uFFFF'; - - private final Collator mCollator; - - StringMatcher() { - // On android N and above, Collator uses ICU implementation which has a much better - // support for non-latin locales. - mCollator = Collator.getInstance(); - mCollator.setStrength(Collator.PRIMARY); - mCollator.setDecomposition(Collator.CANONICAL_DECOMPOSITION); - } - - /** - * Returns true if {@param query} is a prefix of {@param target} - */ - public boolean matches(String query, String target) { - switch (mCollator.compare(query, target)) { - case 0: - return true; - case -1: - // The target string can contain a modifier which would make it larger than - // the query string (even though the length is same). If the query becomes - // larger after appending a unicode character, it was originally a prefix of - // the target string and hence should match. - return mCollator.compare(query + MAX_UNICODE, target) > -1; - default: - return false; - } - } - - public static StringMatcher getInstance() { - return new StringMatcher(); - } - } - - private static boolean requestSimpleFuzzySearch(String s) { - for (int i = 0; i < s.length(); ) { - int codepoint = s.codePointAt(i); - i += Character.charCount(codepoint); - switch (Character.UnicodeScript.of(codepoint)) { - case HAN: - //Character.UnicodeScript.HAN: use String.contains to match - return true; - } - } - return false; - } } diff --git a/src/com/android/launcher3/search/SearchAlgorithm.java b/src/com/android/launcher3/search/SearchAlgorithm.java index 1665354af2..a1720c733b 100644 --- a/src/com/android/launcher3/search/SearchAlgorithm.java +++ b/src/com/android/launcher3/search/SearchAlgorithm.java @@ -31,4 +31,9 @@ public interface SearchAlgorithm { * Cancels any active request. */ void cancel(boolean interruptActiveRequests); + + /** + * Cleans up after search is no longer needed. + */ + default void destroy() {}; } diff --git a/src/com/android/launcher3/search/StringMatcherUtility.java b/src/com/android/launcher3/search/StringMatcherUtility.java new file mode 100644 index 0000000000..acab52bbe8 --- /dev/null +++ b/src/com/android/launcher3/search/StringMatcherUtility.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.search; + +import java.text.Collator; + +/** + * Utilities for matching query string to target string. + */ +public class StringMatcherUtility { + + /** + * Returns {@code true} is {@code query} is a prefix substring of a complete word/phrase in + * {@code target}. + */ + public static boolean matches(String query, String target, StringMatcher matcher) { + int queryLength = query.length(); + + int targetLength = target.length(); + + if (targetLength < queryLength || queryLength <= 0) { + return false; + } + + if (requestSimpleFuzzySearch(query)) { + return target.toLowerCase().contains(query); + } + + int lastType; + int thisType = Character.UNASSIGNED; + int nextType = Character.getType(target.codePointAt(0)); + + int end = targetLength - queryLength; + for (int i = 0; i <= end; i++) { + lastType = thisType; + thisType = nextType; + nextType = i < (targetLength - 1) + ? Character.getType(target.codePointAt(i + 1)) : Character.UNASSIGNED; + if (isBreak(thisType, lastType, nextType) + && matcher.matches(query, target.substring(i, i + queryLength))) { + return true; + } + } + return false; + } + + /** + * Returns true if the current point should be a break point. Following cases + * are considered as break points: + * 1) Any non space character after a space character + * 2) Any digit after a non-digit character + * 3) Any capital character after a digit or small character + * 4) Any capital character before a small character + */ + private static boolean isBreak(int thisType, int prevType, int nextType) { + switch (prevType) { + case Character.UNASSIGNED: + case Character.SPACE_SEPARATOR: + case Character.LINE_SEPARATOR: + case Character.PARAGRAPH_SEPARATOR: + return true; + } + switch (thisType) { + case Character.UPPERCASE_LETTER: + if (nextType == Character.UPPERCASE_LETTER) { + return true; + } + // Follow through + case Character.TITLECASE_LETTER: + // Break point if previous was not a upper case + return prevType != Character.UPPERCASE_LETTER; + case Character.LOWERCASE_LETTER: + // Break point if previous was not a letter. + return prevType > Character.OTHER_LETTER || prevType <= Character.UNASSIGNED; + case Character.DECIMAL_DIGIT_NUMBER: + case Character.LETTER_NUMBER: + case Character.OTHER_NUMBER: + // Break point if previous was not a number + return !(prevType == Character.DECIMAL_DIGIT_NUMBER + || prevType == Character.LETTER_NUMBER + || prevType == Character.OTHER_NUMBER); + case Character.MATH_SYMBOL: + case Character.CURRENCY_SYMBOL: + case Character.OTHER_PUNCTUATION: + case Character.DASH_PUNCTUATION: + // Always a break point for a symbol + return true; + default: + return false; + } + } + + /** + * Performs locale sensitive string comparison using {@link Collator}. + */ + public static class StringMatcher { + + private static final char MAX_UNICODE = '\uFFFF'; + + private final Collator mCollator; + + StringMatcher() { + // On android N and above, Collator uses ICU implementation which has a much better + // support for non-latin locales. + mCollator = Collator.getInstance(); + mCollator.setStrength(Collator.PRIMARY); + mCollator.setDecomposition(Collator.CANONICAL_DECOMPOSITION); + } + + /** + * Returns true if {@param query} is a prefix of {@param target} + */ + public boolean matches(String query, String target) { + switch (mCollator.compare(query, target)) { + case 0: + return true; + case -1: + // The target string can contain a modifier which would make it larger than + // the query string (even though the length is same). If the query becomes + // larger after appending a unicode character, it was originally a prefix of + // the target string and hence should match. + return mCollator.compare(query + MAX_UNICODE, target) > -1; + default: + return false; + } + } + + public static StringMatcher getInstance() { + return new StringMatcher(); + } + } + + /** + * Matching optimization to search in Chinese. + */ + private static boolean requestSimpleFuzzySearch(String s) { + for (int i = 0; i < s.length(); ) { + int codepoint = s.codePointAt(i); + i += Character.charCount(codepoint); + switch (Character.UnicodeScript.of(codepoint)) { + case HAN: + //Character.UnicodeScript.HAN: use String.contains to match + return true; + } + } + return false; + } +} diff --git a/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java b/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java index d09fd49e6e..73bae6f484 100644 --- a/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java +++ b/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java @@ -20,10 +20,14 @@ import static java.lang.annotation.RetentionPolicy.SOURCE; import androidx.annotation.IntDef; +import com.android.launcher3.model.WidgetItem; import com.android.launcher3.model.data.ItemInfo; import com.android.launcher3.model.data.PackageItemInfo; +import com.android.launcher3.widget.WidgetItemComparator; import java.lang.annotation.Retention; +import java.util.List; +import java.util.stream.Collectors; /** Holder class to store the package information of an entry shown in the widgets list. */ public abstract class WidgetsListBaseEntry { @@ -35,9 +39,14 @@ public abstract class WidgetsListBaseEntry { */ public final String mTitleSectionName; - public WidgetsListBaseEntry(PackageItemInfo pkgItem, String titleSectionName) { + public final List mWidgets; + + public WidgetsListBaseEntry(PackageItemInfo pkgItem, String titleSectionName, + List items) { mPkgItem = pkgItem; mTitleSectionName = titleSectionName; + this.mWidgets = + items.stream().sorted(new WidgetItemComparator()).collect(Collectors.toList()); } /** diff --git a/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java b/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java index afc0f443bd..0328cf68ba 100644 --- a/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java +++ b/src/com/android/launcher3/widget/model/WidgetsListContentEntry.java @@ -17,10 +17,8 @@ package com.android.launcher3.widget.model; import com.android.launcher3.model.WidgetItem; import com.android.launcher3.model.data.PackageItemInfo; -import com.android.launcher3.widget.WidgetItemComparator; import java.util.List; -import java.util.stream.Collectors; /** * Holder class to store all the information related to a list of widgets from the same app which is @@ -28,13 +26,9 @@ import java.util.stream.Collectors; */ public final class WidgetsListContentEntry extends WidgetsListBaseEntry { - public final List mWidgets; - public WidgetsListContentEntry(PackageItemInfo pkgItem, String titleSectionName, List items) { - super(pkgItem, titleSectionName); - this.mWidgets = - items.stream().sorted(new WidgetItemComparator()).collect(Collectors.toList()); + super(pkgItem, titleSectionName, items); } @Override @@ -47,4 +41,12 @@ public final class WidgetsListContentEntry extends WidgetsListBaseEntry { public int getRank() { return RANK_WIDGETS_LIST_CONTENT; } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof WidgetsListContentEntry)) return false; + WidgetsListContentEntry otherEntry = (WidgetsListContentEntry) obj; + return mWidgets.equals(otherEntry.mWidgets) && mPkgItem.equals(otherEntry.mPkgItem) + && mTitleSectionName.equals(otherEntry.mTitleSectionName); + } } diff --git a/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java b/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java index 309b678409..1fdc39959a 100644 --- a/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java +++ b/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java @@ -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 mWidgets; private boolean mIsWidgetListShown = false; private boolean mHasEntryUpdated = false; public WidgetsListHeaderEntry(PackageItemInfo pkgItem, String titleSectionName, List 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); } diff --git a/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java b/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java index a8b887bb9e..2aec3f8fd9 100644 --- a/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java +++ b/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java @@ -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 mWidgets; - private boolean mIsWidgetListShown = false; private boolean mHasEntryUpdated = false; public WidgetsListSearchHeaderEntry(PackageItemInfo pkgItem, String titleSectionName, List 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. */ diff --git a/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java b/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java index 99114955aa..5222e8ec59 100644 --- a/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java +++ b/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java @@ -16,12 +16,19 @@ package com.android.launcher3.widget.picker.search; -import com.android.launcher3.widget.model.WidgetsListBaseEntry; +import static com.android.launcher3.search.StringMatcherUtility.matches; + +import com.android.launcher3.model.WidgetItem; +import com.android.launcher3.search.StringMatcherUtility.StringMatcher; +import com.android.launcher3.widget.model.WidgetsListBaseEntry; +import com.android.launcher3.widget.model.WidgetsListContentEntry; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; +import com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry; -import java.text.Collator; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; +import java.util.stream.Collectors; /** * Implementation of {@link WidgetsPickerSearchPipeline} that performs search by prefix matching on @@ -37,52 +44,29 @@ public final class SimpleWidgetsSearchPipeline implements WidgetsPickerSearchPip @Override public void query(String input, Consumer> callback) { - StringMatcher matcher = StringMatcher.getInstance(); ArrayList results = new ArrayList<>(); - // TODO(b/157286785): Filter entries based on query prefix matching on widget labels also. - for (WidgetsListBaseEntry e : mAllEntries) { - if (matcher.matches(input, e.mPkgItem.title.toString())) { - results.add(e); - } - } + mAllEntries.stream().filter(entry -> entry instanceof WidgetsListHeaderEntry) + .forEach(headerEntry -> { + List matchedWidgetItems = filterWidgetItems( + input, headerEntry.mPkgItem.title.toString(), headerEntry.mWidgets); + if (matchedWidgetItems.size() > 0) { + results.add(new WidgetsListSearchHeaderEntry(headerEntry.mPkgItem, + headerEntry.mTitleSectionName, matchedWidgetItems)); + results.add(new WidgetsListContentEntry(headerEntry.mPkgItem, + headerEntry.mTitleSectionName, matchedWidgetItems)); + } + }); callback.accept(results); } - /** - * Performs locale sensitive string comparison using {@link Collator}. - */ - public static class StringMatcher { - - private static final char MAX_UNICODE = '\uFFFF'; - - private final Collator mCollator; - - StringMatcher() { - mCollator = Collator.getInstance(); - mCollator.setStrength(Collator.PRIMARY); - mCollator.setDecomposition(Collator.CANONICAL_DECOMPOSITION); - } - - /** - * Returns true if {@param query} is a prefix of {@param target}. - */ - public boolean matches(String query, String target) { - switch (mCollator.compare(query, target)) { - case 0: - return true; - case -1: - // The target string can contain a modifier which would make it larger than - // the query string (even though the length is same). If the query becomes - // larger after appending a unicode character, it was originally a prefix of - // the target string and hence should match. - return mCollator.compare(query + MAX_UNICODE, target) > -1; - default: - return false; - } - } - - public static StringMatcher getInstance() { - return new StringMatcher(); + private List filterWidgetItems(String query, String packageTitle, + List items) { + StringMatcher matcher = StringMatcher.getInstance(); + if (matches(query, packageTitle, matcher)) { + return items; } + return items.stream() + .filter(item -> matches(query, item.label, matcher)) + .collect(Collectors.toList()); } } diff --git a/src/com/android/launcher3/widget/picker/search/WidgetsSearchBar.java b/src/com/android/launcher3/widget/picker/search/WidgetsSearchBar.java new file mode 100644 index 0000000000..d8e9733ce5 --- /dev/null +++ b/src/com/android/launcher3/widget/picker/search/WidgetsSearchBar.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.widget.picker.search; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.launcher3.R; +import com.android.launcher3.search.SearchAlgorithm; +import com.android.launcher3.widget.model.WidgetsListBaseEntry; + +import java.util.List; + +/** + * View for a search bar with an edit text with a cancel button. + */ +public class WidgetsSearchBar extends LinearLayout { + private WidgetsSearchBarController mController; + private EditText mEditText; + private ImageButton mCancelButton; + + public WidgetsSearchBar(Context context) { + this(context, null, 0); + } + + public WidgetsSearchBar(@NonNull Context context, + @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public WidgetsSearchBar(@NonNull Context context, @Nullable AttributeSet attrs, + int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + /** + * Attaches a controller to the search bar which interacts with {@code searchModeListener}. + */ + public void initialize(List allWidgets, + SearchModeListener searchModeListener) { + SearchAlgorithm algo = + new SimpleWidgetsSearchAlgorithm(new SimpleWidgetsSearchPipeline(allWidgets)); + mController = new WidgetsSearchBarController( + algo, mEditText, mCancelButton, searchModeListener); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mEditText = findViewById(R.id.widgets_search_bar_edit_text); + mCancelButton = findViewById(R.id.widgets_search_cancel_button); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mController.onDestroy(); + } +} diff --git a/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarController.java b/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarController.java new file mode 100644 index 0000000000..6c374842fd --- /dev/null +++ b/src/com/android/launcher3/widget/picker/search/WidgetsSearchBarController.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.widget.picker.search; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import android.text.Editable; +import android.text.TextWatcher; +import android.util.Log; +import android.widget.EditText; +import android.widget.ImageButton; + +import com.android.launcher3.search.SearchAlgorithm; +import com.android.launcher3.search.SearchCallback; +import com.android.launcher3.widget.model.WidgetsListBaseEntry; + +import java.util.ArrayList; + +/** + * Controller for a search bar with an edit text and a cancel button. + */ +public class WidgetsSearchBarController implements TextWatcher, + SearchCallback { + private static final String TAG = "WidgetsSearchBarController"; + private static final boolean DEBUG = false; + + protected SearchAlgorithm mSearchAlgorithm; + protected EditText mInput; + protected ImageButton mCancelButton; + protected SearchModeListener mSearchModeListener; + protected String mQuery; + + public WidgetsSearchBarController( + SearchAlgorithm algo, EditText editText, ImageButton cancelButton, + SearchModeListener searchModeListener) { + mSearchAlgorithm = algo; + mInput = editText; + mInput.addTextChangedListener(this); + mCancelButton = cancelButton; + mCancelButton.setOnClickListener(v -> clearSearchResult()); + mSearchModeListener = searchModeListener; + } + + @Override + public void afterTextChanged(final Editable s) { + mQuery = s.toString(); + if (mQuery.isEmpty()) { + mSearchAlgorithm.cancel(/* interruptActiveRequests= */ true); + mSearchModeListener.exitSearchMode(); + mCancelButton.setVisibility(GONE); + } else { + mSearchAlgorithm.cancel(/* interruptActiveRequests= */ false); + mSearchModeListener.enterSearchMode(); + mSearchAlgorithm.doSearch(mQuery, this); + mCancelButton.setVisibility(VISIBLE); + } + } + + @Override + public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { + // Do nothing. + } + + @Override + public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { + // Do nothing. + } + + @Override + public void onSearchResult(String query, ArrayList items) { + if (DEBUG) { + Log.d(TAG, "onSearchResult query: " + query + " items: " + items); + } + mSearchModeListener.onSearchResults(items); + } + + @Override + public void onAppendSearchResult(String query, ArrayList items) { + // Not needed. + } + + @Override + public void clearSearchResult() { + mSearchAlgorithm.cancel(/* interruptActiveRequests= */ true); + mInput.getText().clear(); + mInput.clearFocus(); + mSearchModeListener.exitSearchMode(); + } + + /** + * Cleans up after search is no longer needed. + */ + public void onDestroy() { + mSearchAlgorithm.destroy(); + } +} diff --git a/tests/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithmTest.java b/tests/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithmTest.java deleted file mode 100644 index 39709a9539..0000000000 --- a/tests/src/com/android/launcher3/allapps/search/DefaultAppSearchAlgorithmTest.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (C) 2016 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.launcher3.allapps.search; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import android.content.ComponentName; - -import androidx.test.filters.SmallTest; -import androidx.test.runner.AndroidJUnit4; - -import com.android.launcher3.model.data.AppInfo; - -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * Unit tests for {@link DefaultAppSearchAlgorithm} - */ -@SmallTest -@RunWith(AndroidJUnit4.class) -public class DefaultAppSearchAlgorithmTest { - private static final DefaultAppSearchAlgorithm.StringMatcher MATCHER = - DefaultAppSearchAlgorithm.StringMatcher.getInstance(); - - @Test - public void testMatches() { - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("white cow"), "cow", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("whiteCow"), "cow", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("whiteCOW"), "cow", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("whitecowCOW"), "cow", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("white2cow"), "cow", MATCHER)); - - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("whitecow"), "cow", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("whitEcow"), "cow", MATCHER)); - - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("whitecowCow"), "cow", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("whitecow cow"), "cow", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("whitecowcow"), "cow", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("whit ecowcow"), "cow", MATCHER)); - - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("cats&dogs"), "dog", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("cats&Dogs"), "dog", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("cats&Dogs"), "&", MATCHER)); - - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("2+43"), "43", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("2+43"), "3", MATCHER)); - - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("Q"), "q", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo(" Q"), "q", MATCHER)); - - // match lower case words - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("elephant"), "e", MATCHER)); - - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("电子邮件"), "电", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("电子邮件"), "电子", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("电子邮件"), "子", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("电子邮件"), "邮件", MATCHER)); - - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("Bot"), "ba", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("bot"), "ba", MATCHER)); - } - - @Test - public void testMatchesVN() { - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("다운로드"), "다", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("드라이브"), "드", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("다운로드 드라이브"), "ㄷ", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("운로 드라이브"), "ㄷ", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("abc"), "åbç", MATCHER)); - assertTrue(DefaultAppSearchAlgorithm.matches(getInfo("Alpha"), "ål", MATCHER)); - - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("다운로드 드라이브"), "ㄷㄷ", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("로드라이브"), "ㄷ", MATCHER)); - assertFalse(DefaultAppSearchAlgorithm.matches(getInfo("abc"), "åç", MATCHER)); - } - - private AppInfo getInfo(String title) { - AppInfo info = new AppInfo(); - info.title = title; - info.componentName = new ComponentName("Test", title); - return info; - } -} diff --git a/tests/src/com/android/launcher3/search/StringMatcherUtilityTest.java b/tests/src/com/android/launcher3/search/StringMatcherUtilityTest.java new file mode 100644 index 0000000000..413f404406 --- /dev/null +++ b/tests/src/com/android/launcher3/search/StringMatcherUtilityTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.search; + +import static com.android.launcher3.search.StringMatcherUtility.matches; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import com.android.launcher3.search.StringMatcherUtility.StringMatcher; + +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Unit tests for {@link StringMatcherUtility} + */ +@SmallTest +@RunWith(AndroidJUnit4.class) +public class StringMatcherUtilityTest { + private static final StringMatcher MATCHER = + StringMatcher.getInstance(); + + @Test + public void testMatches() { + assertTrue(matches("white ", "white cow", MATCHER)); + assertTrue(matches("white c", "white cow", MATCHER)); + assertTrue(matches("cow", "white cow", MATCHER)); + assertTrue(matches("cow", "whiteCow", MATCHER)); + assertTrue(matches("cow", "whiteCOW", MATCHER)); + assertTrue(matches("cow", "whitecowCOW", MATCHER)); + assertTrue(matches("cow", "white2cow", MATCHER)); + + assertFalse(matches("cow", "whitecow", MATCHER)); + assertFalse(matches("cow", "whitEcow", MATCHER)); + + assertTrue(matches("cow", "whitecowCow", MATCHER)); + assertTrue(matches("cow", "whitecow cow", MATCHER)); + assertFalse(matches("cow", "whitecowcow", MATCHER)); + assertFalse(matches("cow", "whit ecowcow", MATCHER)); + + assertTrue(matches("dog", "cats&dogs", MATCHER)); + assertTrue(matches("dog", "cats&Dogs", MATCHER)); + assertTrue(matches("&", "cats&Dogs", MATCHER)); + + assertTrue(matches("43", "2+43", MATCHER)); + assertFalse(matches("3", "2+43", MATCHER)); + + assertTrue(matches("q", "Q", MATCHER)); + assertTrue(matches("q", " Q", MATCHER)); + + // match lower case words + assertTrue(matches("e", "elephant", MATCHER)); + assertTrue(matches("eL", "Elephant", MATCHER)); + + assertTrue(matches("电", "电子邮件", MATCHER)); + assertTrue(matches("电子", "电子邮件", MATCHER)); + assertTrue(matches("子", "电子邮件", MATCHER)); + assertTrue(matches("邮件", "电子邮件", MATCHER)); + + assertFalse(matches("ba", "Bot", MATCHER)); + assertFalse(matches("ba", "bot", MATCHER)); + assertFalse(matches("phant", "elephant", MATCHER)); + assertFalse(matches("elephants", "elephant", MATCHER)); + } + + @Test + public void testMatchesVN() { + assertTrue(matches("다", "다운로드", MATCHER)); + assertTrue(matches("드", "드라이브", MATCHER)); + assertTrue(matches("ㄷ", "다운로드 드라이브", MATCHER)); + assertTrue(matches("ㄷ", "운로 드라이브", MATCHER)); + assertTrue(matches("åbç", "abc", MATCHER)); + assertTrue(matches("ål", "Alpha", MATCHER)); + + assertFalse(matches("ㄷㄷ", "다운로드 드라이브", MATCHER)); + assertFalse(matches("ㄷ", "로드라이브", MATCHER)); + assertFalse(matches("åç", "abc", MATCHER)); + } +}