From c6fe0dd4fa5d5113e96fffbdf27293cfbd302468 Mon Sep 17 00:00:00 2001 From: Alina Zaidi Date: Wed, 3 Mar 2021 18:40:01 +0000 Subject: [PATCH] Implement pipeline for a simple search based on query string matching on app names in WidgetsListBaseEntries. This will be the default search for AOSP widget picker and a fallback search for Pixel widget picker. Test: Tested prototype locally. Also added robolectric test. Bug: b/157286785 Change-Id: Iad3bf2f46b2a89383a52c756fd1b9f65ecbeb40b --- .../SimpleWidgetsSearchAlgorithmTest.java | 81 ++++++++ .../SimpleWidgetsSearchPipelineTest.java | 182 ++++++++++++++++++ .../search/SimpleWidgetsSearchAlgorithm.java | 63 ++++++ .../search/SimpleWidgetsSearchPipeline.java | 88 +++++++++ 4 files changed, 414 insertions(+) create mode 100644 robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchAlgorithmTest.java create mode 100644 robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipelineTest.java create mode 100644 src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchAlgorithm.java create mode 100644 src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchAlgorithmTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchAlgorithmTest.java new file mode 100644 index 0000000000..c2bf1ae2dc --- /dev/null +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchAlgorithmTest.java @@ -0,0 +1,81 @@ +/* + * 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.os.Looper.getMainLooper; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.verify; +import static org.robolectric.Shadows.shadowOf; + +import com.android.launcher3.search.SearchCallback; +import com.android.launcher3.widget.model.WidgetsListBaseEntry; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +@RunWith(RobolectricTestRunner.class) +public class SimpleWidgetsSearchAlgorithmTest { + + private SimpleWidgetsSearchAlgorithm mSimpleWidgetsSearchAlgorithm; + @Mock + private WidgetsPickerSearchPipeline mSearchPipeline; + @Mock + private SearchCallback mSearchCallback; + @Captor + private ArgumentCaptor>> mConsumerCaptor; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mSimpleWidgetsSearchAlgorithm = new SimpleWidgetsSearchAlgorithm(mSearchPipeline); + } + + @Test + public void doSearch_shouldQueryPipeline() { + mSimpleWidgetsSearchAlgorithm.doSearch("abc", mSearchCallback); + + verify(mSearchPipeline).query(eq("abc"), any()); + } + + @Test + public void doSearch_shouldInformSearchCallbackOnQueryResult() { + ArrayList baseEntries = new ArrayList<>(); + + mSimpleWidgetsSearchAlgorithm.doSearch("abc", mSearchCallback); + + verify(mSearchPipeline).query(eq("abc"), mConsumerCaptor.capture()); + mConsumerCaptor.getValue().accept(baseEntries); + shadowOf(getMainLooper()).idle(); + // Verify SearchCallback#onSearchResult receives a query token along with the search + // results. The query token is the original query string concatenated with the query + // timestamp. + verify(mSearchCallback).onSearchResult(matches("abc\t\\d*"), eq(baseEntries)); + } +} 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 new file mode 100644 index 0000000000..8aebf12b89 --- /dev/null +++ b/robolectric_tests/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipelineTest.java @@ -0,0 +1,182 @@ +/* + * 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.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; + +import android.appwidget.AppWidgetProviderInfo; +import android.content.ComponentName; +import android.content.Context; +import android.graphics.Bitmap; +import android.os.UserHandle; + +import com.android.launcher3.InvariantDeviceProfile; +import com.android.launcher3.icons.BitmapInfo; +import com.android.launcher3.icons.ComponentWithLabel; +import com.android.launcher3.icons.IconCache; +import com.android.launcher3.model.WidgetItem; +import com.android.launcher3.model.data.PackageItemInfo; +import com.android.launcher3.widget.LauncherAppWidgetProviderInfo; +import com.android.launcher3.widget.model.WidgetsListContentEntry; +import com.android.launcher3.widget.model.WidgetsListHeaderEntry; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.shadows.ShadowPackageManager; +import org.robolectric.util.ReflectionHelpers; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class SimpleWidgetsSearchPipelineTest { + private static final SimpleWidgetsSearchPipeline.StringMatcher MATCHER = + SimpleWidgetsSearchPipeline.StringMatcher.getInstance(); + + @Mock private IconCache mIconCache; + + private InvariantDeviceProfile mTestProfile; + private WidgetsListHeaderEntry mCalendarHeaderEntry; + private WidgetsListContentEntry mCalendarContentEntry; + private WidgetsListHeaderEntry mCameraHeaderEntry; + private WidgetsListContentEntry mCameraContentEntry; + private WidgetsListHeaderEntry mClockHeaderEntry; + private WidgetsListContentEntry mClockContentEntry; + private Context mContext; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + doAnswer(invocation -> ((ComponentWithLabel) invocation.getArgument(0)) + .getComponent().getPackageName()) + .when(mIconCache).getTitleNoCache(any()); + mTestProfile = new InvariantDeviceProfile(); + mTestProfile.numRows = 5; + mTestProfile.numColumns = 5; + mContext = RuntimeEnvironment.application; + + mCalendarHeaderEntry = + 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); + mClockHeaderEntry = createWidgetsHeaderEntry("com.example.android.Clock", "Clock", 3); + mClockContentEntry = createWidgetsContentEntry("com.example.android.Clock", "Clock", 3); + } + + @Test + public void query_shouldInformCallbackWithResultsMatchedOnAppName() { + SimpleWidgetsSearchPipeline pipeline = new SimpleWidgetsSearchPipeline( + List.of(mCalendarHeaderEntry, mCalendarContentEntry, mCameraHeaderEntry, + mCameraContentEntry, mClockHeaderEntry, mClockContentEntry)); + + pipeline.query("Ca", results -> + assertEquals(results, List.of(mCalendarHeaderEntry, mCalendarContentEntry, + mCameraHeaderEntry, 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")); + + 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")); + } + + private WidgetsListHeaderEntry createWidgetsHeaderEntry(String packageName, String appName, + int numOfWidgets) { + List widgetItems = generateWidgetItems(packageName, numOfWidgets); + PackageItemInfo pInfo = createPackageItemInfo(packageName, appName, + widgetItems.get(0).user); + + return new WidgetsListHeaderEntry(pInfo, /* titleSectionName= */ "", widgetItems); + } + + private WidgetsListContentEntry createWidgetsContentEntry(String packageName, String appName, + int numOfWidgets) { + List widgetItems = generateWidgetItems(packageName, numOfWidgets); + PackageItemInfo pInfo = createPackageItemInfo(packageName, appName, + widgetItems.get(0).user); + + return new WidgetsListContentEntry(pInfo, /* titleSectionName= */ "", widgetItems); + } + + private PackageItemInfo createPackageItemInfo(String packageName, String appName, + UserHandle userHandle) { + PackageItemInfo pInfo = new PackageItemInfo(packageName); + pInfo.title = appName; + pInfo.user = userHandle; + pInfo.bitmap = BitmapInfo.of(Bitmap.createBitmap(10, 10, Bitmap.Config.ALPHA_8), 0); + return pInfo; + } + + private List generateWidgetItems(String packageName, int numOfWidgets) { + ShadowPackageManager packageManager = shadowOf(mContext.getPackageManager()); + ArrayList widgetItems = new ArrayList<>(); + for (int i = 0; i < numOfWidgets; i++) { + ComponentName cn = ComponentName.createRelative(packageName, ".SampleWidget" + i); + AppWidgetProviderInfo widgetInfo = new AppWidgetProviderInfo(); + widgetInfo.provider = cn; + ReflectionHelpers.setField(widgetInfo, "providerInfo", + packageManager.addReceiverIfNotPresent(cn)); + + WidgetItem widgetItem = new WidgetItem( + LauncherAppWidgetProviderInfo.fromProviderInfo(mContext, widgetInfo), + mTestProfile, mIconCache); + widgetItems.add(widgetItem); + } + return widgetItems; + } +} diff --git a/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchAlgorithm.java b/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchAlgorithm.java new file mode 100644 index 0000000000..15d245486b --- /dev/null +++ b/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchAlgorithm.java @@ -0,0 +1,63 @@ +/* + * 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.os.Handler; +import android.util.Log; + +import com.android.launcher3.search.SearchAlgorithm; +import com.android.launcher3.search.SearchCallback; +import com.android.launcher3.widget.model.WidgetsListBaseEntry; + +import java.util.ArrayList; + +/** + * Implementation of {@link SearchAlgorithm} that posts a task to query on the main thread. + */ +public final class SimpleWidgetsSearchAlgorithm implements SearchAlgorithm { + + private static final boolean DEBUG = false; + private static final String TAG = "SimpleWidgetsSearchAlgo"; + private static final String DELIM = "\t"; + + private final Handler mResultHandler; + private final WidgetsPickerSearchPipeline mSearchPipeline; + + public SimpleWidgetsSearchAlgorithm(WidgetsPickerSearchPipeline searchPipeline) { + mResultHandler = new Handler(); + mSearchPipeline = searchPipeline; + } + + @Override + public void doSearch(String query, SearchCallback callback) { + long startTime = System.currentTimeMillis(); + String queryToken = query + DELIM + startTime; + if (DEBUG) { + Log.d(TAG, "doSearch queryToken:" + queryToken); + } + mSearchPipeline.query(query, + results -> mResultHandler.post( + () -> callback.onSearchResult(queryToken, new ArrayList(results)))); + } + + @Override + public void cancel(boolean interruptActiveRequests) { + if (interruptActiveRequests) { + mResultHandler.removeCallbacksAndMessages(/*token= */null); + } + } +} diff --git a/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java b/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java new file mode 100644 index 0000000000..99114955aa --- /dev/null +++ b/src/com/android/launcher3/widget/picker/search/SimpleWidgetsSearchPipeline.java @@ -0,0 +1,88 @@ +/* + * 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.text.Collator; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Implementation of {@link WidgetsPickerSearchPipeline} that performs search by prefix matching on + * app names and widget labels. + */ +public final class SimpleWidgetsSearchPipeline implements WidgetsPickerSearchPipeline { + + private final List mAllEntries; + + public SimpleWidgetsSearchPipeline(List allEntries) { + mAllEntries = allEntries; + } + + @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); + } + } + 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(); + } + } +}