blob: d29ca051ad9411611c39afb0cbd9bd28b5d4a876 [file] [log] [blame]
/*
* Copyright (C) 2017 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.paramsToString;
import static com.android.server.autofill.Helper.sDebug;
import static com.android.server.autofill.Helper.sFullScreenMode;
import static com.android.server.autofill.Helper.sVerbose;
import static com.android.server.autofill.Helper.sVisibleDatasetsMaxCount;
import android.annotation.AttrRes;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.content.pm.PackageManager;
import android.graphics.Point;
import android.graphics.Rect;
import android.service.autofill.Dataset;
import android.service.autofill.Dataset.DatasetFieldFilter;
import android.service.autofill.FillResponse;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Slog;
import android.util.TypedValue;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityManager;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillValue;
import android.view.autofill.IAutofillWindowPresenter;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.RemoteViews;
import com.android.internal.R;
import com.android.server.UiThread;
import com.android.server.autofill.Helper;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
final class FillUi {
private static final String TAG = "FillUi";
private static final TypedValue sTempTypedValue = new TypedValue();
public static final class AutofillFrameLayout extends FrameLayout {
OnKeyListener mUnhandledListener;
public AutofillFrameLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public AutofillFrameLayout(Context context, AttributeSet attrs, @AttrRes int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
boolean handled = super.dispatchKeyEvent(event);
if (!handled) {
handled = mUnhandledListener.onKey(this, event.getKeyCode(), event);
}
return handled;
}
}
interface Callback {
void onResponsePicked(@NonNull FillResponse response);
void onDatasetPicked(@NonNull Dataset dataset);
void onCanceled();
void onDestroy();
void requestShowFillUi(int width, int height,
IAutofillWindowPresenter windowPresenter);
void requestHideFillUi();
void startIntentSender(IntentSender intentSender);
void dispatchUnhandledKey(KeyEvent keyEvent);
}
private final @NonNull Point mTempPoint = new Point();
private final @NonNull AutofillWindowPresenter mWindowPresenter =
new AutofillWindowPresenter();
private final @NonNull Context mContext;
private final @NonNull AnchoredWindow mWindow;
private final @NonNull Callback mCallback;
private final @Nullable View mHeader;
private final @NonNull ListView mListView;
private final @Nullable View mFooter;
private final @Nullable ItemsAdapter mAdapter;
private @Nullable String mFilterText;
private @Nullable AnnounceFilterResult mAnnounceFilterResult;
private final boolean mFullScreen;
private final int mVisibleDatasetsMaxCount;
private int mContentWidth;
private int mContentHeight;
private boolean mDestroyed;
public static boolean isFullScreen(Context context) {
if (sFullScreenMode != null) {
if (sVerbose) Slog.v(TAG, "forcing full-screen mode to " + sFullScreenMode);
return sFullScreenMode;
}
return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK);
}
FillUi(@NonNull Context context, @NonNull FillResponse response,
@NonNull AutofillId focusedViewId, @NonNull @Nullable String filterText,
@NonNull OverlayControl overlayControl, @NonNull Callback callback) {
mContext = context;
mCallback = callback;
mFullScreen = isFullScreen(context);
final LayoutInflater inflater = LayoutInflater.from(context);
final RemoteViews headerPresentation = response.getHeader();
final RemoteViews footerPresentation = response.getFooter();
final ViewGroup decor;
if (headerPresentation != null || footerPresentation != null) {
decor = (ViewGroup) inflater.inflate(
mFullScreen ? R.layout.autofill_dataset_picker_header_footer_fullscreen
: R.layout.autofill_dataset_picker_header_footer, null);
} else {
decor = (ViewGroup) inflater.inflate(
mFullScreen ? R.layout.autofill_dataset_picker_fullscreen
: R.layout.autofill_dataset_picker, null);
}
// if autofill ui is not fullscreen, send unhandled keyevent to app window.
if (!mFullScreen) {
if (decor instanceof AutofillFrameLayout) {
((AutofillFrameLayout) decor).mUnhandledListener =
(View view, int keyCode, KeyEvent event) -> {
switch (keyCode) {
case KeyEvent.KEYCODE_BACK:
case KeyEvent.KEYCODE_ESCAPE:
case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_DPAD_DOWN:
return false;
default:
mCallback.dispatchUnhandledKey(event);
return true;
}
};
} else {
Slog.wtf(TAG, "Unable to send unhandled key");
}
}
if (sVisibleDatasetsMaxCount > 0) {
mVisibleDatasetsMaxCount = sVisibleDatasetsMaxCount;
if (sVerbose) {
Slog.v(TAG, "overriding maximum visible datasets to " + mVisibleDatasetsMaxCount);
}
} else {
mVisibleDatasetsMaxCount = mContext.getResources()
.getInteger(com.android.internal.R.integer.autofill_max_visible_datasets);
}
final RemoteViews.OnClickHandler interceptionHandler = new RemoteViews.OnClickHandler() {
@Override
public boolean onClickHandler(View view, PendingIntent pendingIntent,
Intent fillInIntent) {
if (pendingIntent != null) {
mCallback.startIntentSender(pendingIntent.getIntentSender());
}
return true;
}
};
if (response.getAuthentication() != null) {
mHeader = null;
mListView = null;
mFooter = null;
mAdapter = null;
// insert authentication item under autofill_dataset_container or decor
ViewGroup container = decor.findViewById(R.id.autofill_dataset_container);
if (container == null) {
container = decor;
}
final View content;
try {
content = response.getPresentation().apply(context, decor, interceptionHandler);
container.addView(content);
} catch (RuntimeException e) {
callback.onCanceled();
Slog.e(TAG, "Error inflating remote views", e);
mWindow = null;
return;
}
decor.setFocusable(true);
decor.setOnClickListener(v -> mCallback.onResponsePicked(response));
final Point maxSize = mTempPoint;
resolveMaxWindowSize(context, maxSize);
// fullScreen mode occupy the full width defined by autofill_dataset_picker_max_width
content.getLayoutParams().width = mFullScreen ? maxSize.x
: ViewGroup.LayoutParams.WRAP_CONTENT;
content.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(maxSize.x,
MeasureSpec.AT_MOST);
final int heightMeasureSpec = MeasureSpec.makeMeasureSpec(maxSize.y,
MeasureSpec.AT_MOST);
decor.measure(widthMeasureSpec, heightMeasureSpec);
mContentWidth = content.getMeasuredWidth();
mContentHeight = content.getMeasuredHeight();
mWindow = new AnchoredWindow(decor, overlayControl);
requestShowFillUi();
} else {
final int datasetCount = response.getDatasets().size();
if (sVerbose) {
Slog.v(TAG, "Number datasets: " + datasetCount + " max visible: "
+ mVisibleDatasetsMaxCount);
}
RemoteViews.OnClickHandler clickBlocker = null;
if (headerPresentation != null) {
clickBlocker = newClickBlocker();
mHeader = headerPresentation.apply(context, null, clickBlocker);
final LinearLayout headerContainer =
decor.findViewById(R.id.autofill_dataset_header);
if (sVerbose) Slog.v(TAG, "adding header");
headerContainer.addView(mHeader);
headerContainer.setVisibility(View.VISIBLE);
} else {
mHeader = null;
}
if (footerPresentation != null) {
if (clickBlocker == null) { // already set for header
clickBlocker = newClickBlocker();
}
mFooter = footerPresentation.apply(context, null, clickBlocker);
final LinearLayout footerContainer =
decor.findViewById(R.id.autofill_dataset_footer);
if (sVerbose) Slog.v(TAG, "adding footer");
footerContainer.addView(mFooter);
footerContainer.setVisibility(View.VISIBLE);
} else {
mFooter = null;
}
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) {
final RemoteViews 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.apply(context, null, interceptionHandler);
} catch (RuntimeException e) {
Slog.e(TAG, "Error inflating remote views", e);
continue;
}
final 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));
}
}
mAdapter = new ItemsAdapter(items);
mListView = decor.findViewById(R.id.autofill_dataset_list);
mListView.setAdapter(mAdapter);
mListView.setVisibility(View.VISIBLE);
mListView.setOnItemClickListener((adapter, view, position, id) -> {
final ViewItem vi = mAdapter.getItem(position);
mCallback.onDatasetPicked(vi.dataset);
});
if (filterText == null) {
mFilterText = null;
} else {
mFilterText = filterText.toLowerCase();
}
applyNewFilterText();
mWindow = new AnchoredWindow(decor, overlayControl);
}
}
void requestShowFillUi() {
if (mFullScreen) {
mCallback.requestShowFillUi(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT,
mWindowPresenter);
} else {
mCallback.requestShowFillUi(mContentWidth, mContentHeight, mWindowPresenter);
}
}
/**
* Creates a remoteview interceptor used to block clicks.
*/
private RemoteViews.OnClickHandler newClickBlocker() {
return new RemoteViews.OnClickHandler() {
@Override
public boolean onClickHandler(View view, PendingIntent pendingIntent,
Intent fillInIntent) {
if (sVerbose) Slog.v(TAG, "Ignoring click on " + view);
return true;
}
};
}
private void applyNewFilterText() {
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.requestHideFillUi();
} else {
if (updateContentSize()) {
if (mFullScreen) {
LayoutParams lp = mListView.getLayoutParams();
lp.width = mContentWidth;
lp.height = mContentHeight;
mListView.setLayoutParams(lp);
}
requestShowFillUi();
}
if (mAdapter.getCount() > mVisibleDatasetsMaxCount) {
mListView.setVerticalScrollBarEnabled(true);
mListView.onVisibilityAggregated(true);
} else {
mListView.setVerticalScrollBarEnabled(false);
}
if (mAdapter.getCount() != oldCount) {
mListView.requestLayout();
}
}
});
}
public void setFilterText(@Nullable String filterText) {
throwIfDestroyed();
if (mAdapter == null) {
// ViewState doesn't not support filtering - typically when it's for an authenticated
// FillResponse.
if (TextUtils.isEmpty(filterText)) {
requestShowFillUi();
} else {
mCallback.requestHideFillUi();
}
return;
}
if (filterText == null) {
filterText = null;
} else {
filterText = filterText.toLowerCase();
}
if (Objects.equals(mFilterText, filterText)) {
return;
}
mFilterText = filterText;
applyNewFilterText();
}
public void destroy(boolean notifyClient) {
throwIfDestroyed();
if (mWindow != null) {
mWindow.hide(false);
}
mCallback.onDestroy();
if (notifyClient) {
mCallback.requestHideFillUi();
}
mDestroyed = true;
}
private boolean updateContentSize() {
if (mAdapter == null) {
return false;
}
boolean changed = false;
if (mAdapter.getCount() <= 0) {
if (mContentWidth != 0) {
mContentWidth = 0;
changed = true;
}
if (mContentHeight != 0) {
mContentHeight = 0;
changed = true;
}
return changed;
}
Point maxSize = mTempPoint;
resolveMaxWindowSize(mContext, maxSize);
mContentWidth = 0;
mContentHeight = 0;
final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(maxSize.x,
MeasureSpec.AT_MOST);
final int heightMeasureSpec = MeasureSpec.makeMeasureSpec(maxSize.y,
MeasureSpec.AT_MOST);
final int itemCount = mAdapter.getCount();
if (mFullScreen) {
// fullScreen mode occupy the full width defined by autofill_dataset_picker_max_width
changed = true;
mContentWidth = maxSize.x;
}
if (mHeader != null) {
mHeader.measure(widthMeasureSpec, heightMeasureSpec);
changed |= updateWidth(mHeader, maxSize);
changed |= updateHeight(mHeader, maxSize);
}
for (int i = 0; i < itemCount; i++) {
final View view = mAdapter.getItem(i).view;
view.measure(widthMeasureSpec, heightMeasureSpec);
if (mFullScreen) {
// for fullscreen, add up all children height until hit max height.
final int newContentHeight = mContentHeight + view.getMeasuredHeight();
final int clampedNewHeight = Math.min(newContentHeight, maxSize.y);
if (clampedNewHeight != mContentHeight) {
mContentHeight = clampedNewHeight;
} else if (view.getMeasuredHeight() > 0) {
break;
}
} else {
changed |= updateWidth(view, maxSize);
if (i < mVisibleDatasetsMaxCount) {
changed |= updateHeight(view, maxSize);
}
}
}
if (mFooter != null) {
mFooter.measure(widthMeasureSpec, heightMeasureSpec);
changed |= updateWidth(mFooter, maxSize);
changed |= updateHeight(mFooter, maxSize);
}
return changed;
}
private boolean updateWidth(View view, Point maxSize) {
boolean changed = false;
final int clampedMeasuredWidth = Math.min(view.getMeasuredWidth(), maxSize.x);
final int newContentWidth = Math.max(mContentWidth, clampedMeasuredWidth);
if (newContentWidth != mContentWidth) {
mContentWidth = newContentWidth;
changed = true;
}
return changed;
}
private boolean updateHeight(View view, Point maxSize) {
boolean changed = false;
final int clampedMeasuredHeight = Math.min(view.getMeasuredHeight(), maxSize.y);
final int newContentHeight = mContentHeight + clampedMeasuredHeight;
if (newContentHeight != mContentHeight) {
mContentHeight = newContentHeight;
changed = true;
}
return changed;
}
private void throwIfDestroyed() {
if (mDestroyed) {
throw new IllegalStateException("cannot interact with a destroyed instance");
}
}
private static void resolveMaxWindowSize(Context context, Point outPoint) {
context.getDisplay().getSize(outPoint);
final TypedValue typedValue = sTempTypedValue;
context.getTheme().resolveAttribute(R.attr.autofillDatasetPickerMaxWidth,
typedValue, true);
outPoint.x = (int) typedValue.getFraction(outPoint.x, outPoint.x);
context.getTheme().resolveAttribute(R.attr.autofillDatasetPickerMaxHeight,
typedValue, true);
outPoint.y = (int) typedValue.getFraction(outPoint.y, outPoint.y);
}
/**
* 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 or {@code null} if it's a header or
* footer (TODO(b/69796626): make @NonNull if header/footer is refactored out of the list)
* @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(@Nullable 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();
}
}
private final class AutofillWindowPresenter extends IAutofillWindowPresenter.Stub {
@Override
public void show(WindowManager.LayoutParams p, Rect transitionEpicenter,
boolean fitsSystemWindows, int layoutDirection) {
if (sVerbose) {
Slog.v(TAG, "AutofillWindowPresenter.show(): fit=" + fitsSystemWindows
+ ", params=" + paramsToString(p));
}
UiThread.getHandler().post(() -> mWindow.show(p));
}
@Override
public void hide(Rect transitionEpicenter) {
UiThread.getHandler().post(mWindow::hide);
}
}
final class AnchoredWindow {
private final @NonNull OverlayControl mOverlayControl;
private final WindowManager mWm;
private final View mContentView;
private boolean mShowing;
// Used on dump only
private WindowManager.LayoutParams mShowParams;
/**
* Constructor.
*
* @param contentView content of the window
*/
AnchoredWindow(View contentView, @NonNull OverlayControl overlayControl) {
mWm = contentView.getContext().getSystemService(WindowManager.class);
mContentView = contentView;
mOverlayControl = overlayControl;
}
/**
* Shows the window.
*/
public void show(WindowManager.LayoutParams params) {
mShowParams = params;
if (sVerbose) {
Slog.v(TAG, "show(): showing=" + mShowing + ", params=" + paramsToString(params));
}
try {
params.packageName = "android";
params.setTitle("Autofill UI"); // Title is set for debugging purposes
if (!mShowing) {
params.accessibilityTitle = mContentView.getContext()
.getString(R.string.autofill_picker_accessibility_title);
mWm.addView(mContentView, params);
mOverlayControl.hideOverlays();
mShowing = true;
} else {
mWm.updateViewLayout(mContentView, params);
}
} catch (WindowManager.BadTokenException e) {
if (sDebug) Slog.d(TAG, "Filed with with token " + params.token + " gone.");
mCallback.onDestroy();
} catch (IllegalStateException e) {
// WM throws an ISE if mContentView was added twice; this should never happen -
// since show() and hide() are always called in the UIThread - but when it does,
// it should not crash the system.
Slog.e(TAG, "Exception showing window " + params, e);
mCallback.onDestroy();
}
}
/**
* Hides the window.
*/
void hide() {
hide(true);
}
void hide(boolean destroyCallbackOnError) {
try {
if (mShowing) {
mWm.removeView(mContentView);
mShowing = false;
}
} catch (IllegalStateException e) {
// WM might thrown an ISE when removing the mContentView; this should never
// happen - since show() and hide() are always called in the UIThread - but if it
// does, it should not crash the system.
Slog.e(TAG, "Exception hiding window ", e);
if (destroyCallbackOnError) {
mCallback.onDestroy();
}
} finally {
mOverlayControl.showOverlays();
}
}
}
public void dump(PrintWriter pw, String prefix) {
pw.print(prefix); pw.print("mCallback: "); pw.println(mCallback != null);
pw.print(prefix); pw.print("mFullScreen: "); pw.println(mFullScreen);
pw.print(prefix); pw.print("mVisibleDatasetsMaxCount: "); pw.println(
mVisibleDatasetsMaxCount);
if (mHeader != null) {
pw.print(prefix); pw.print("mHeader: "); pw.println(mHeader);
}
if (mListView != null) {
pw.print(prefix); pw.print("mListView: "); pw.println(mListView);
}
if (mFooter != null) {
pw.print(prefix); pw.print("mFooter: "); pw.println(mFooter);
}
if (mAdapter != null) {
pw.print(prefix); pw.print("mAdapter: "); pw.println(mAdapter);
}
if (mFilterText != null) {
pw.print(prefix); pw.print("mFilterText: ");
Helper.printlnRedactedText(pw, mFilterText);
}
pw.print(prefix); pw.print("mContentWidth: "); pw.println(mContentWidth);
pw.print(prefix); pw.print("mContentHeight: "); pw.println(mContentHeight);
pw.print(prefix); pw.print("mDestroyed: "); pw.println(mDestroyed);
if (mWindow != null) {
pw.print(prefix); pw.print("mWindow: ");
final String prefix2 = prefix + " ";
pw.println();
pw.print(prefix2); pw.print("showing: "); pw.println(mWindow.mShowing);
pw.print(prefix2); pw.print("view: "); pw.println(mWindow.mContentView);
if (mWindow.mShowParams != null) {
pw.print(prefix2); pw.print("params: "); pw.println(mWindow.mShowParams);
}
pw.print(prefix2); pw.print("screen coordinates: ");
if (mWindow.mContentView == null) {
pw.println("N/A");
} else {
final int[] coordinates = mWindow.mContentView.getLocationOnScreen();
pw.print(coordinates[0]); pw.print("x"); pw.println(coordinates[1]);
}
}
}
private void announceSearchResultIfNeeded() {
if (AccessibilityManager.getInstance(mContext).isEnabled()) {
if (mAnnounceFilterResult == null) {
mAnnounceFilterResult = new AnnounceFilterResult();
}
mAnnounceFilterResult.post();
}
}
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 + "]";
}
}
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 {
text = mContext.getResources().getQuantityString(
R.plurals.autofill_picker_some_suggestions, count, count);
}
mListView.announceForAccessibility(text);
}
}
}