Add bottom sheet fill UI for Autofill

Create the fill dialog UI. Its layout is similar to FillUi but uses
dialog styles similar to SaveUi.

Bug: 210926084
Test: Manual
Change-Id: I9e5dfcc1cd0afdc8cc26ede401418dbfa2467d23
This commit is contained in:
TYM Tsai
2021-12-14 15:37:22 +08:00
committed by Ahaan Ugale
parent 2e6dda883b
commit 33571fb894
5 changed files with 809 additions and 4 deletions

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2022 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.
-->
<!-- NOTE: outer layout is required to provide proper shadow. -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/autofill_dialog_picker"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/autofill_save_outer_top_margin"
android:padding="@dimen/autofill_save_outer_top_padding"
android:elevation="@dimen/autofill_elevation"
android:background="?android:attr/colorBackground"
android:orientation="vertical">
<LinearLayout
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:paddingStart="@dimen/autofill_save_inner_padding"
android:paddingEnd="@dimen/autofill_save_inner_padding"
android:orientation="vertical">
<ImageView
android:id="@+id/autofill_service_icon"
android:scaleType="fitStart"
android:visibility="gone"
android:layout_width="@dimen/autofill_dialog_icon_size"
android:layout_height="@dimen/autofill_dialog_icon_size"/>
<LinearLayout
android:id="@+id/autofill_dialog_header"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:paddingStart="@dimen/autofill_save_inner_padding"
android:paddingEnd="@dimen/autofill_save_inner_padding"
android:visibility="gone"
android:foreground="?attr/listChoiceBackgroundIndicator"
style="@style/AutofillDatasetPicker" />
</LinearLayout>
<LinearLayout
android:id="@+id/autofill_dialog_container"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:paddingStart="@dimen/autofill_save_inner_padding"
android:paddingEnd="@dimen/autofill_save_inner_padding"
android:visibility="gone"
android:foreground="?attr/listChoiceBackgroundIndicator"
style="@style/AutofillDatasetPicker" />
<ListView
android:id="@+id/autofill_dialog_list"
android:layout_weight="1"
android:layout_width="fill_parent"
android:layout_height="0dp"
android:drawSelectorOnTop="true"
android:clickable="true"
android:divider="@null"
android:visibility="gone"
android:paddingStart="@dimen/autofill_save_inner_padding"
android:paddingEnd="@dimen/autofill_save_inner_padding"
android:foreground="?attr/listChoiceBackgroundIndicator"
style="@style/AutofillDatasetPicker" />
<com.android.internal.widget.ButtonBarLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:padding="@dimen/autofill_save_button_bar_padding"
android:clipToPadding="false"
android:orientation="horizontal">
<Button
android:id="@+id/autofill_dialog_no"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?android:attr/buttonBarButtonStyle"
android:text="@string/dismiss_action">
</Button>
<Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="invisible">
</Space>
<Button
android:id="@+id/autofill_dialog_yes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.DeviceDefault.Button.Colored"
android:text="@string/autofill_save_yes"
android:visibility="gone" >
</Button>
</com.android.internal.widget.ButtonBarLayout>
</LinearLayout>

View File

@@ -877,6 +877,9 @@
<!-- Maximum number of datasets that are visible in the UX picker without scrolling -->
<integer name="autofill_max_visible_datasets">3</integer>
<!-- Size of an icon in the Autolfill fill dialog -->
<dimen name="autofill_dialog_icon_size">56dp</dimen>
<!-- Size of a slice shortcut view -->
<dimen name="slice_shortcut_size">56dp</dimen>
<!-- Size of action icons in a slice -->

View File

@@ -3504,6 +3504,7 @@
<java-symbol type="layout" name="autofill_dataset_picker"/>
<java-symbol type="layout" name="autofill_dataset_picker_fullscreen"/>
<java-symbol type="layout" name="autofill_dataset_picker_header_footer"/>
<java-symbol type="layout" name="autofill_fill_dialog"/>
<java-symbol type="id" name="autofill" />
<java-symbol type="id" name="autofill_dataset_footer"/>
<java-symbol type="id" name="autofill_dataset_header"/>
@@ -3516,6 +3517,13 @@
<java-symbol type="id" name="autofill_save_no" />
<java-symbol type="id" name="autofill_save_title" />
<java-symbol type="id" name="autofill_save_yes" />
<java-symbol type="id" name="autofill_service_icon" />
<java-symbol type="id" name="autofill_dialog_picker"/>
<java-symbol type="id" name="autofill_dialog_header"/>
<java-symbol type="id" name="autofill_dialog_container"/>
<java-symbol type="id" name="autofill_dialog_list"/>
<java-symbol type="id" name="autofill_dialog_no" />
<java-symbol type="id" name="autofill_dialog_yes" />
<java-symbol type="string" name="autofill_error_cannot_autofill" />
<java-symbol type="string" name="autofill_picker_no_suggestions" />
<java-symbol type="string" name="autofill_picker_some_suggestions" />

View File

@@ -66,6 +66,7 @@ public final class AutoFillUI {
private @Nullable FillUi mFillUi;
private @Nullable SaveUi mSaveUi;
private @Nullable DialogFillUi mFillDialog;
private @Nullable AutoFillUiCallback mCallback;
@@ -387,7 +388,48 @@ public final class AutoFillUI {
+ componentName.toShortString() + ": " + response);
}
// TODO show fill dialog
// TODO: enable LogMaker
mHandler.post(() -> {
if (callback != mCallback) {
return;
}
hideAllUiThread(callback);
mFillDialog = new DialogFillUi(mContext, response, focusedId, filterText,
serviceIcon, servicePackageName, componentName, mOverlayControl,
mUiModeMgr.isNightMode(), new DialogFillUi.UiCallback() {
@Override
public void onResponsePicked(FillResponse response) {
hideFillDialogUiThread(callback);
if (mCallback != null) {
mCallback.authenticate(response.getRequestId(),
AutofillManager.AUTHENTICATION_ID_DATASET_ID_UNDEFINED,
response.getAuthentication(), response.getClientState(),
/* authenticateInline= */ false);
}
}
@Override
public void onDatasetPicked(Dataset dataset) {
hideFillDialogUiThread(callback);
if (mCallback != null) {
final int datasetIndex = response.getDatasets().indexOf(dataset);
mCallback.fill(response.getRequestId(), datasetIndex, dataset);
}
}
@Override
public void onCanceled() {
hideFillDialogUiThread(callback);
callback.requestShowSoftInput(focusedId);
}
@Override
public void startIntentSender(IntentSender intentSender) {
mCallback.startIntentSenderAndFinishSession(intentSender);
}
});
});
}
/**
@@ -423,8 +465,7 @@ public final class AutoFillUI {
}
public boolean isFillDialogShowing() {
// TODO
return false;
return mFillDialog == null ? false : mFillDialog.isShowing();
}
public void dump(PrintWriter pw) {
@@ -444,6 +485,12 @@ public final class AutoFillUI {
} else {
pw.print(prefix); pw.println("showsSaveUi: false");
}
if (mFillDialog != null) {
pw.print(prefix); pw.println("showsFillDialog: true");
mFillDialog.dump(pw, prefix2);
} else {
pw.print(prefix); pw.println("showsFillDialog: false");
}
}
@android.annotation.UiThread
@@ -470,7 +517,10 @@ public final class AutoFillUI {
@android.annotation.UiThread
private void hideFillDialogUiThread(@Nullable AutoFillUiCallback callback) {
// TODO
if (mFillDialog != null && (callback == null || callback == mCallback)) {
mFillDialog.destroy();
mFillDialog = null;
}
}
@android.annotation.UiThread

View File

@@ -0,0 +1,629 @@
/*
* Copyright (C) 2022 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.server.autofill.ui;
import static com.android.server.autofill.Helper.sDebug;
import static com.android.server.autofill.Helper.sVerbose;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.Dialog;
import android.content.ComponentName;
import android.content.Context;
import android.content.IntentSender;
import android.graphics.drawable.Drawable;
import android.service.autofill.Dataset;
import android.service.autofill.FillResponse;
import android.text.TextUtils;
import android.util.PluralsMessageFormatter;
import android.util.Slog;
import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityManager;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillValue;
import android.widget.AdapterView;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.RemoteViews;
import android.widget.TextView;
import com.android.internal.R;
import com.android.server.autofill.AutofillManagerService;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* A dialog to show Autofill suggestions.
*
* This fill dialog UI shows as a bottom sheet style dialog. This dialog UI
* provides a larger area to display the suggestions, it provides a more
* conspicuous and efficient interface to the user. So it is easy for users
* to pay attention to the datasets and selecting one of them.
*/
final class DialogFillUi {
private static final String TAG = "DialogFillUi";
private static final int THEME_ID_LIGHT =
R.style.Theme_DeviceDefault_Light_Autofill_Save;
private static final int THEME_ID_DARK =
R.style.Theme_DeviceDefault_Autofill_Save;
interface UiCallback {
void onResponsePicked(@NonNull FillResponse response);
void onDatasetPicked(@NonNull Dataset dataset);
void onCanceled();
void startIntentSender(IntentSender intentSender);
}
private final @NonNull Dialog mDialog;
private final @NonNull OverlayControl mOverlayControl;
private final String mServicePackageName;
private final ComponentName mComponentName;
private final int mThemeId;
private final @NonNull Context mContext;
private final @NonNull UiCallback mCallback;
private final @NonNull ListView mListView;
private final @Nullable ItemsAdapter mAdapter;
private final int mVisibleDatasetsMaxCount;
private @Nullable String mFilterText;
private @Nullable AnnounceFilterResult mAnnounceFilterResult;
private boolean mDestroyed;
DialogFillUi(@NonNull Context context, @NonNull FillResponse response,
@NonNull AutofillId focusedViewId, @Nullable String filterText,
@Nullable Drawable serviceIcon, @Nullable String servicePackageName,
@Nullable ComponentName componentName, @NonNull OverlayControl overlayControl,
boolean nightMode, @NonNull UiCallback callback) {
if (sVerbose) Slog.v(TAG, "nightMode: " + nightMode);
mThemeId = nightMode ? THEME_ID_DARK : THEME_ID_LIGHT;
mCallback = callback;
mOverlayControl = overlayControl;
mServicePackageName = servicePackageName;
mComponentName = componentName;
mContext = new ContextThemeWrapper(context, mThemeId);
final LayoutInflater inflater = LayoutInflater.from(mContext);
final View decor = inflater.inflate(R.layout.autofill_fill_dialog, null);
setServiceIcon(decor, serviceIcon);
setHeader(decor, response);
mVisibleDatasetsMaxCount = getVisibleDatasetsMaxCount();
if (response.getAuthentication() != null) {
mListView = null;
mAdapter = null;
try {
initialAuthenticationLayout(decor, response);
} catch (RuntimeException e) {
callback.onCanceled();
Slog.e(TAG, "Error inflating remote views", e);
mDialog = null;
return;
}
} else {
final List<ViewItem> items = createDatasetItems(response, focusedViewId);
mAdapter = new ItemsAdapter(items);
mListView = decor.findViewById(R.id.autofill_dialog_list);
initialDatasetLayout(decor, filterText);
}
setDismissButton(decor);
mDialog = new Dialog(mContext, mThemeId);
mDialog.setContentView(decor);
setDialogParamsAsBottomSheet();
show();
}
private int getVisibleDatasetsMaxCount() {
if (AutofillManagerService.getVisibleDatasetsMaxCount() > 0) {
final int maxCount = AutofillManagerService.getVisibleDatasetsMaxCount();
if (sVerbose) {
Slog.v(TAG, "overriding maximum visible datasets to " + maxCount);
}
return maxCount;
} else {
return mContext.getResources()
.getInteger(com.android.internal.R.integer.autofill_max_visible_datasets);
}
}
private void setDialogParamsAsBottomSheet() {
final Window window = mDialog.getWindow();
window.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH);
window.addPrivateFlags(WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS);
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN);
window.setGravity(Gravity.BOTTOM | Gravity.CENTER);
window.setCloseOnTouchOutside(true);
final WindowManager.LayoutParams params = window.getAttributes();
params.width = WindowManager.LayoutParams.MATCH_PARENT;
params.accessibilityTitle =
mContext.getString(R.string.autofill_picker_accessibility_title);
params.windowAnimations = R.style.AutofillSaveAnimation;
}
private void setServiceIcon(View decor, Drawable serviceIcon) {
if (serviceIcon == null) {
return;
}
final ImageView iconView = decor.findViewById(R.id.autofill_service_icon);
final int actualWidth = serviceIcon.getMinimumWidth();
final int actualHeight = serviceIcon.getMinimumHeight();
if (sDebug) {
Slog.d(TAG, "Adding service icon "
+ "(" + actualWidth + "x" + actualHeight + ")");
}
iconView.setImageDrawable(serviceIcon);
iconView.setVisibility(View.VISIBLE);
}
private void setHeader(View decor, FillResponse response) {
final RemoteViews presentation = response.getDialogHeader();
if (presentation == null) {
return;
}
final ViewGroup container = decor.findViewById(R.id.autofill_dialog_header);
final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> {
if (pendingIntent != null) {
mCallback.startIntentSender(pendingIntent.getIntentSender());
}
return true;
};
final View content = presentation.applyWithTheme(
mContext, (ViewGroup) decor, interceptionHandler, mThemeId);
container.addView(content);
container.setVisibility(View.VISIBLE);
}
private void setDismissButton(View decor) {
final TextView noButton = decor.findViewById(R.id.autofill_dialog_no);
noButton.setOnClickListener((v) -> mCallback.onCanceled());
}
private void setContinueButton(View decor, View.OnClickListener listener) {
final TextView yesButton = decor.findViewById(R.id.autofill_dialog_yes);
// set "Continue" by default
yesButton.setText(R.string.autofill_continue_yes);
yesButton.setOnClickListener(listener);
}
private void initialAuthenticationLayout(View decor, FillResponse response) {
RemoteViews presentation = response.getDialogPresentation();
if (presentation == null) {
presentation = response.getPresentation();
}
if (presentation == null) {
throw new RuntimeException("No presentation for fill dialog authentication");
}
// insert authentication item under autofill_dialog_container
final ViewGroup container = decor.findViewById(R.id.autofill_dialog_container);
final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> {
if (pendingIntent != null) {
mCallback.startIntentSender(pendingIntent.getIntentSender());
}
return true;
};
final View content = presentation.applyWithTheme(
mContext, (ViewGroup) decor, interceptionHandler, mThemeId);
container.addView(content);
container.setVisibility(View.VISIBLE);
container.setFocusable(true);
container.setOnClickListener(v -> mCallback.onResponsePicked(response));
// just single item, set up continue button
setContinueButton(decor, v -> mCallback.onResponsePicked(response));
}
private ArrayList<ViewItem> createDatasetItems(FillResponse response,
AutofillId focusedViewId) {
final int datasetCount = response.getDatasets().size();
if (sVerbose) {
Slog.v(TAG, "Number datasets: " + datasetCount + " max visible: "
+ mVisibleDatasetsMaxCount);
}
final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> {
if (pendingIntent != null) {
mCallback.startIntentSender(pendingIntent.getIntentSender());
}
return true;
};
final ArrayList<ViewItem> items = new ArrayList<>(datasetCount);
for (int i = 0; i < datasetCount; i++) {
final Dataset dataset = response.getDatasets().get(i);
final int index = dataset.getFieldIds().indexOf(focusedViewId);
if (index >= 0) {
RemoteViews presentation = dataset.getFieldDialogPresentation(index);
if (presentation == null) {
Slog.w(TAG, "fallback to presentation");
presentation = dataset.getFieldPresentation(index);
}
if (presentation == null) {
Slog.w(TAG, "not displaying UI on field " + focusedViewId + " because "
+ "service didn't provide a presentation for it on " + dataset);
continue;
}
final View view;
try {
if (sVerbose) Slog.v(TAG, "setting remote view for " + focusedViewId);
view = presentation.applyWithTheme(
mContext, null, interceptionHandler, mThemeId);
} catch (RuntimeException e) {
Slog.e(TAG, "Error inflating remote views", e);
continue;
}
// TODO: Extract the shared filtering logic here and in FillUi to a common
// method.
final Dataset.DatasetFieldFilter filter = dataset.getFilter(index);
Pattern filterPattern = null;
String valueText = null;
boolean filterable = true;
if (filter == null) {
final AutofillValue value = dataset.getFieldValues().get(index);
if (value != null && value.isText()) {
valueText = value.getTextValue().toString().toLowerCase();
}
} else {
filterPattern = filter.pattern;
if (filterPattern == null) {
if (sVerbose) {
Slog.v(TAG, "Explicitly disabling filter at id " + focusedViewId
+ " for dataset #" + index);
}
filterable = false;
}
}
items.add(new ViewItem(dataset, filterPattern, filterable, valueText, view));
}
}
return items;
}
private void initialDatasetLayout(View decor, String filterText) {
final AdapterView.OnItemClickListener onItemClickListener =
(adapter, view, position, id) -> {
final ViewItem vi = mAdapter.getItem(position);
mCallback.onDatasetPicked(vi.dataset);
};
mListView.setAdapter(mAdapter);
mListView.setVisibility(View.VISIBLE);
mListView.setOnItemClickListener(onItemClickListener);
if (mAdapter.getCount() == 1) {
// just single item, set up continue button
setContinueButton(decor, (v) ->
onItemClickListener.onItemClick(null, null, 0, 0));
}
if (filterText == null) {
mFilterText = null;
} else {
mFilterText = filterText.toLowerCase();
}
final int oldCount = mAdapter.getCount();
mAdapter.getFilter().filter(mFilterText, (count) -> {
if (mDestroyed) {
return;
}
if (count <= 0) {
if (sDebug) {
final int size = mFilterText == null ? 0 : mFilterText.length();
Slog.d(TAG, "No dataset matches filter with " + size + " chars");
}
mCallback.onCanceled();
} else {
if (mAdapter.getCount() > mVisibleDatasetsMaxCount) {
mListView.setVerticalScrollBarEnabled(true);
mListView.onVisibilityAggregated(true);
} else {
mListView.setVerticalScrollBarEnabled(false);
}
if (mAdapter.getCount() != oldCount) {
mListView.requestLayout();
}
}
});
}
private void show() {
Slog.i(TAG, "Showing fill dialog");
mDialog.show();
mOverlayControl.hideOverlays();
}
boolean isShowing() {
return mDialog.isShowing();
}
void hide() {
if (sVerbose) Slog.v(TAG, "Hiding fill dialog.");
try {
mDialog.hide();
} finally {
mOverlayControl.showOverlays();
}
}
void destroy() {
try {
if (sDebug) Slog.d(TAG, "destroy()");
throwIfDestroyed();
mDialog.dismiss();
mDestroyed = true;
} finally {
mOverlayControl.showOverlays();
}
}
private void throwIfDestroyed() {
if (mDestroyed) {
throw new IllegalStateException("cannot interact with a destroyed instance");
}
}
@Override
public String toString() {
// TODO toString
return "NO TITLE";
}
void dump(PrintWriter pw, String prefix) {
pw.print(prefix); pw.print("service: "); pw.println(mServicePackageName);
pw.print(prefix); pw.print("app: "); pw.println(mComponentName.toShortString());
pw.print(prefix); pw.print("theme id: "); pw.print(mThemeId);
switch (mThemeId) {
case THEME_ID_DARK:
pw.println(" (dark)");
break;
case THEME_ID_LIGHT:
pw.println(" (light)");
break;
default:
pw.println("(UNKNOWN_MODE)");
break;
}
final View view = mDialog.getWindow().getDecorView();
final int[] loc = view.getLocationOnScreen();
pw.print(prefix); pw.print("coordinates: ");
pw.print('('); pw.print(loc[0]); pw.print(','); pw.print(loc[1]); pw.print(')');
pw.print('(');
pw.print(loc[0] + view.getWidth()); pw.print(',');
pw.print(loc[1] + view.getHeight()); pw.println(')');
pw.print(prefix); pw.print("destroyed: "); pw.println(mDestroyed);
}
private void announceSearchResultIfNeeded() {
if (AccessibilityManager.getInstance(mContext).isEnabled()) {
if (mAnnounceFilterResult == null) {
mAnnounceFilterResult = new AnnounceFilterResult();
}
mAnnounceFilterResult.post();
}
}
// TODO: Below code copied from FullUi, Extract the shared filtering logic here
// and in FillUi to a common method.
private final class AnnounceFilterResult implements Runnable {
private static final int SEARCH_RESULT_ANNOUNCEMENT_DELAY = 1000; // 1 sec
public void post() {
remove();
mListView.postDelayed(this, SEARCH_RESULT_ANNOUNCEMENT_DELAY);
}
public void remove() {
mListView.removeCallbacks(this);
}
@Override
public void run() {
final int count = mListView.getAdapter().getCount();
final String text;
if (count <= 0) {
text = mContext.getString(R.string.autofill_picker_no_suggestions);
} else {
Map<String, Object> arguments = new HashMap<>();
arguments.put("count", count);
text = PluralsMessageFormatter.format(mContext.getResources(),
arguments,
R.string.autofill_picker_some_suggestions);
}
mListView.announceForAccessibility(text);
}
}
private final class ItemsAdapter extends BaseAdapter implements Filterable {
private @NonNull final List<ViewItem> mAllItems;
private @NonNull final List<ViewItem> mFilteredItems = new ArrayList<>();
ItemsAdapter(@NonNull List<ViewItem> items) {
mAllItems = Collections.unmodifiableList(new ArrayList<>(items));
mFilteredItems.addAll(items);
}
@Override
public Filter getFilter() {
return new Filter() {
@Override
protected FilterResults performFiltering(CharSequence filterText) {
// No locking needed as mAllItems is final an immutable
final List<ViewItem> filtered = mAllItems.stream()
.filter((item) -> item.matches(filterText))
.collect(Collectors.toList());
final FilterResults results = new FilterResults();
results.values = filtered;
results.count = filtered.size();
return results;
}
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
final boolean resultCountChanged;
final int oldItemCount = mFilteredItems.size();
mFilteredItems.clear();
if (results.count > 0) {
@SuppressWarnings("unchecked") final List<ViewItem> items =
(List<ViewItem>) results.values;
mFilteredItems.addAll(items);
}
resultCountChanged = (oldItemCount != mFilteredItems.size());
if (resultCountChanged) {
announceSearchResultIfNeeded();
}
notifyDataSetChanged();
}
};
}
@Override
public int getCount() {
return mFilteredItems.size();
}
@Override
public ViewItem getItem(int position) {
return mFilteredItems.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
return getItem(position).view;
}
@Override
public String toString() {
return "ItemsAdapter: [all=" + mAllItems + ", filtered=" + mFilteredItems + "]";
}
}
/**
* An item for the list view - either a (clickable) dataset or a (read-only) header / footer.
*/
private static class ViewItem {
public final @Nullable String value;
public final @Nullable Dataset dataset;
public final @NonNull View view;
public final @Nullable Pattern filter;
public final boolean filterable;
/**
* Default constructor.
*
* @param dataset dataset associated with the item
* @param filter optional filter set by the service to determine how the item should be
* filtered
* @param filterable optional flag set by the service to indicate this item should not be
* filtered (typically used when the dataset has value but it's sensitive, like a password)
* @param value dataset value
* @param view dataset presentation.
*/
ViewItem(@NonNull Dataset dataset, @Nullable Pattern filter, boolean filterable,
@Nullable String value, @NonNull View view) {
this.dataset = dataset;
this.value = value;
this.view = view;
this.filter = filter;
this.filterable = filterable;
}
/**
* Returns whether this item matches the value input by the user so it can be included
* in the filtered datasets.
*/
public boolean matches(CharSequence filterText) {
if (TextUtils.isEmpty(filterText)) {
// Always show item when the user input is empty
return true;
}
if (!filterable) {
// Service explicitly disabled filtering using a null Pattern.
return false;
}
final String constraintLowerCase = filterText.toString().toLowerCase();
if (filter != null) {
// Uses pattern provided by service
return filter.matcher(constraintLowerCase).matches();
} else {
// Compares it with dataset value with dataset
return (value == null)
? (dataset.getAuthentication() == null)
: value.toLowerCase().startsWith(constraintLowerCase);
}
}
@Override
public String toString() {
final StringBuilder builder = new StringBuilder("ViewItem:[view=")
.append(view.getAutofillId());
final String datasetId = dataset == null ? null : dataset.getId();
if (datasetId != null) {
builder.append(", dataset=").append(datasetId);
}
if (value != null) {
// Cannot print value because it could contain PII
builder.append(", value=").append(value.length()).append("_chars");
}
if (filterable) {
builder.append(", filterable");
}
if (filter != null) {
// Filter should not have PII, but it could be a huge regexp
builder.append(", filter=").append(filter.pattern().length()).append("_chars");
}
return builder.append(']').toString();
}
}
}