content: /*
   * 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 android.annotation.NonNull;
  import android.annotation.Nullable;
  import android.content.Context;
  import android.content.IntentSender;
  import android.content.pm.PackageManager;
  import android.graphics.Point;
  import android.graphics.Rect;
  import android.graphics.drawable.Drawable;
  import android.service.autofill.Dataset;
  import android.service.autofill.Dataset.DatasetFieldFilter;
  import android.service.autofill.FillResponse;
  import android.text.TextUtils;
  import android.util.Slog;
  import android.util.TypedValue;
  import android.view.ContextThemeWrapper;
  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.ImageView;
  import android.widget.LinearLayout;
  import android.widget.ListView;
  import android.widget.RemoteViews;
  import android.widget.TextView;
  
  import com.android.internal.R;
  import com.android.server.UiThread;
  import com.android.server.autofill.AutofillManagerService;
  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 int THEME_ID_LIGHT =
              com.android.internal.R.style.Theme_DeviceDefault_Light_Autofill;
      private static final int THEME_ID_DARK =
              com.android.internal.R.style.Theme_DeviceDefault_Autofill;
  
      private static final TypedValue sTempTypedValue = new TypedValue();
  
      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;
  
      private final int mThemeId;
  
      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 CharSequence serviceLabel,
             @NonNull Drawable serviceIcon, boolean nightMode, @NonNull Callback callback) {
          if (sVerbose) Slog.v(TAG, "nightMode: " + nightMode);
          mThemeId = nightMode ? THEME_ID_DARK : THEME_ID_LIGHT;
          mCallback = callback;
          mFullScreen = isFullScreen(context);
          mContext = new ContextThemeWrapper(context, mThemeId);
  
          final LayoutInflater inflater = LayoutInflater.from(mContext);
  
          final RemoteViews headerPresentation = response.getHeader();
          final RemoteViews footerPresentation = response.getFooter();
          final ViewGroup decor;
          if (mFullScreen) {
              decor = (ViewGroup) inflater.inflate(R.layout.autofill_dataset_picker_fullscreen, null);
          } else if (headerPresentation != null || footerPresentation != null) {
              decor = (ViewGroup) inflater.inflate(R.layout.autofill_dataset_picker_header_footer,
                      null);
          } else {
              decor = (ViewGroup) inflater.inflate(R.layout.autofill_dataset_picker, null);
          }
          decor.setClipToOutline(true);
          final TextView titleView = decor.findViewById(R.id.autofill_dataset_title);
          if (titleView != null) {
              titleView.setText(mContext.getString(R.string.autofill_window_title, serviceLabel));
          }
          final ImageView iconView = decor.findViewById(R.id.autofill_dataset_icon);
          if (iconView != null) {
              iconView.setImageDrawable(serviceIcon);
          }
  
          // In full screen we only initialize size once assuming screen size never changes
          if (mFullScreen) {
              final Point outPoint = mTempPoint;
              mContext.getDisplay().getSize(outPoint);
              // full with of screen and half height of screen
              mContentWidth = LayoutParams.MATCH_PARENT;
              mContentHeight = outPoint.y / 2;
              if (sVerbose) {
                  Slog.v(TAG, "initialized fillscreen LayoutParams "
                          + mContentWidth + "," + mContentHeight);
              }
          }
  
          // Send unhandled keyevent to app window.
          decor.addOnUnhandledKeyEventListener((View view, KeyEvent event) -> {
              switch (event.getKeyCode() ) {
                  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;
              }
          });
  
          if (AutofillManagerService.getVisibleDatasetsMaxCount() > 0) {
              mVisibleDatasetsMaxCount = AutofillManagerService.getVisibleDatasetsMaxCount();
              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 = (view, pendingIntent, r) -> {
              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_picker
              ViewGroup container = decor.findViewById(R.id.autofill_dataset_picker);
              final View content;
              try {
                  content = response.getPresentation().applyWithTheme(
                          mContext, decor, interceptionHandler, mThemeId);
                  container.addView(content);
              } catch (RuntimeException e) {
                  callback.onCanceled();
                  Slog.e(TAG, "Error inflating remote views", e);
                  mWindow = null;
                  return;
              }
              container.setFocusable(true);
              container.setOnClickListener(v -> mCallback.onResponsePicked(response));
  
              if (!mFullScreen) {
                  final Point maxSize = mTempPoint;
                  resolveMaxWindowSize(mContext, 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.applyWithTheme(mContext, null, clickBlocker, mThemeId);
                  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) {
                  final LinearLayout footerContainer =
                          decor.findViewById(R.id.autofill_dataset_footer);
                  if (footerContainer != null) {
                      if (clickBlocker == null) { // already set for header
                          clickBlocker = newClickBlocker();
                      }
                      mFooter = footerPresentation.applyWithTheme(
                              mContext, null, clickBlocker, mThemeId);
                      // Footer not supported on some platform e.g. TV
                      if (sVerbose) Slog.v(TAG, "adding footer");
                      footerContainer.addView(mFooter);
                      footerContainer.setVisibility(View.VISIBLE);
                  } else {
                      mFooter = null;
                  }
              } 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.applyWithTheme(
                                  mContext, null, interceptionHandler, mThemeId);
                      } 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() {
          mCallback.requestShowFillUi(mContentWidth, mContentHeight, mWindowPresenter);
      }
  
      /**
       * Creates a remoteview interceptor used to block clicks.
       */
      private RemoteViews.OnClickHandler newClickBlocker() {
          return (view, pendingIntent, response) -> {
              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()) {
                      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;
          }
          if (mFullScreen) {
              // always request show fill window with fixed size for fullscreen
              return true;
          }
          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 (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);
              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.wtf(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);
          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;
          }
          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);
          }
      }
  }
  