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
This commit is contained in:
Alina Zaidi
2021-03-03 18:40:01 +00:00
parent 39938cbc94
commit c6fe0dd4fa
4 changed files with 414 additions and 0 deletions
@@ -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<WidgetsListBaseEntry> mSearchCallback;
@Captor
private ArgumentCaptor<Consumer<List<WidgetsListBaseEntry>>> 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<WidgetsListBaseEntry> 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));
}
}
@@ -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<WidgetItem> 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<WidgetItem> 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<WidgetItem> generateWidgetItems(String packageName, int numOfWidgets) {
ShadowPackageManager packageManager = shadowOf(mContext.getPackageManager());
ArrayList<WidgetItem> widgetItems = new ArrayList<>();
for (int i = 0; i < numOfWidgets; i++) {
ComponentName cn = ComponentName.createRelative(packageName, ".SampleWidget" + i);
AppWidgetProviderInfo widgetInfo = new AppWidgetProviderInfo();
widgetInfo.provider = cn;
ReflectionHelpers.setField(widgetInfo, "providerInfo",
packageManager.addReceiverIfNotPresent(cn));
WidgetItem widgetItem = new WidgetItem(
LauncherAppWidgetProviderInfo.fromProviderInfo(mContext, widgetInfo),
mTestProfile, mIconCache);
widgetItems.add(widgetItem);
}
return widgetItems;
}
}
@@ -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<WidgetsListBaseEntry> {
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<WidgetsListBaseEntry> 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);
}
}
}
@@ -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<WidgetsListBaseEntry> mAllEntries;
public SimpleWidgetsSearchPipeline(List<WidgetsListBaseEntry> allEntries) {
mAllEntries = allEntries;
}
@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);
}
}
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();
}
}
}