| /* |
| * 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.sDebug; |
| import static com.android.server.autofill.Helper.sVerbose; |
| |
| 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.MotionEvent; |
| 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.ListView; |
| import android.widget.RemoteViews; |
| |
| import com.android.internal.R; |
| import com.android.server.UiThread; |
| import com.android.server.autofill.Helper; |
| |
| import static com.android.server.autofill.Helper.sVisibleDatasetsMaxCount; |
| |
| 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 @NonNull ListView mListView; |
| |
| private final @Nullable ItemsAdapter mAdapter; |
| |
| private @Nullable String mFilterText; |
| |
| private @Nullable AnnounceFilterResult mAnnounceFilterResult; |
| |
| private final boolean mFullScreen; |
| private int mContentWidth; |
| private int mContentHeight; |
| |
| private boolean mDestroyed; |
| |
| public static boolean isFullScreen(Context context) { |
| 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 ViewGroup 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"); |
| } |
| } |
| |
| 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) { |
| mListView = 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)); |
| |
| 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(); |
| |
| // Total items include the (optional) header and footer - we cannot use listview's |
| // addHeader() and addFooter() because it would complicate the scrolling logic. |
| int totalItems = datasetCount; |
| |
| RemoteViews.OnClickHandler clickBlocker = null; |
| final RemoteViews headerPresentation = response.getHeader(); |
| View header = null; |
| if (headerPresentation != null) { |
| clickBlocker = newClickBlocker(); |
| header = headerPresentation.apply(context, null, clickBlocker); |
| totalItems++; |
| } |
| |
| final RemoteViews footerPresentation = response.getFooter(); |
| View footer = null; |
| if (footerPresentation != null) { |
| if (clickBlocker == null) { // already set for header |
| clickBlocker = newClickBlocker(); |
| } |
| footer = footerPresentation.apply(context, null, clickBlocker); |
| totalItems++; |
| } |
| if (sVerbose) { |
| Slog.v(TAG, "Number datasets: " + datasetCount + " Total items: " + totalItems); |
| } |
| |
| final ArrayList<ViewItem> items = new ArrayList<>(totalItems); |
| if (header != null) { |
| if (sVerbose) Slog.v(TAG, "adding header"); |
| items.add(new ViewItem(null, null, false, null, header)); |
| } |
| 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)); |
| } |
| } |
| if (footer != null) { |
| if (sVerbose) Slog.v(TAG, "adding footer"); |
| items.add(new ViewItem(null, null, false, null, footer)); |
| } |
| |
| 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); |
| if (vi.dataset == null) { |
| // Clicked on header or footer; ignore. |
| if (sDebug) Slog.d(TAG, "Ignoring click on item " + position + ": " + view); |
| return; |
| } |
| 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() > sVisibleDatasetsMaxCount) { |
| 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() { |
| throwIfDestroyed(); |
| mCallback.onDestroy(); |
| 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; |
| } |
| 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 { |
| final int clampedMeasuredWidth = Math.min(view.getMeasuredWidth(), maxSize.x); |
| final int newContentWidth = Math.max(mContentWidth, clampedMeasuredWidth); |
| if (newContentWidth != mContentWidth) { |
| mContentWidth = newContentWidth; |
| changed = true; |
| } |
| // Update the width to fit only the first items up to max count |
| if (i < sVisibleDatasetsMaxCount) { |
| 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); |
| 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() { |
| return "ViewItem: [dataset=" + (dataset == null ? "null" : dataset.getId()) |
| + ", value=" + (value == null ? "null" : value.length() + "_chars") |
| + ", filterable=" + filterable |
| + ", filter=" + (filter == null ? "null" : filter.pattern().length() + "_chars") |
| + ", view=" + view.getAutofillId() |
| + "]"; |
| } |
| } |
| |
| 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 |
| + ", epicenter="+ transitionEpicenter + ", dir=" + layoutDirection |
| + ", params=" + 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; |
| |
| /** |
| * 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) { |
| if (sVerbose) Slog.v(TAG, "show(): showing=" + mShowing + ", params="+ params); |
| try { |
| // Okay here is a bit of voodoo - we want to show the window as system |
| // controlled one so it covers app windows - adjust the params accordingly. |
| params.type = WindowManager.LayoutParams.TYPE_SYSTEM_DIALOG; |
| params.token = null; |
| params.packageName = "android"; |
| params.flags |= WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN; |
| 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() { |
| 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); |
| 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("mListView: "); pw.println(mListView); |
| pw.print(prefix); pw.print("mAdapter: "); pw.println(mAdapter); |
| 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); |
| pw.print(prefix); pw.print("mWindow: "); |
| if (mWindow == null) { |
| pw.println("N/A"); |
| } else { |
| 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); |
| 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); |
| } |
| } |
| } |