From 33571fb894c33033594bdf4f47d57f81b22b34ca Mon Sep 17 00:00:00 2001 From: TYM Tsai Date: Tue, 14 Dec 2021 15:37:22 +0800 Subject: [PATCH] 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 --- core/res/res/layout/autofill_fill_dialog.xml | 115 ++++ core/res/res/values/dimens.xml | 3 + core/res/res/values/symbols.xml | 8 + .../server/autofill/ui/AutoFillUI.java | 58 +- .../server/autofill/ui/DialogFillUi.java | 629 ++++++++++++++++++ 5 files changed, 809 insertions(+), 4 deletions(-) create mode 100644 core/res/res/layout/autofill_fill_dialog.xml create mode 100644 services/autofill/java/com/android/server/autofill/ui/DialogFillUi.java diff --git a/core/res/res/layout/autofill_fill_dialog.xml b/core/res/res/layout/autofill_fill_dialog.xml new file mode 100644 index 0000000000000..cce9593f1f2a3 --- /dev/null +++ b/core/res/res/layout/autofill_fill_dialog.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/res/res/values/dimens.xml b/core/res/res/values/dimens.xml index d374b74aa947c..df3c1634db81e 100644 --- a/core/res/res/values/dimens.xml +++ b/core/res/res/values/dimens.xml @@ -877,6 +877,9 @@ 3 + + 56dp + 56dp diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml index 1ee85ea5ebe98..ff9e2af59fbc7 100644 --- a/core/res/res/values/symbols.xml +++ b/core/res/res/values/symbols.xml @@ -3504,6 +3504,7 @@ + @@ -3516,6 +3517,13 @@ + + + + + + + diff --git a/services/autofill/java/com/android/server/autofill/ui/AutoFillUI.java b/services/autofill/java/com/android/server/autofill/ui/AutoFillUI.java index 47fd68c25705f..056ab92fffb21 100644 --- a/services/autofill/java/com/android/server/autofill/ui/AutoFillUI.java +++ b/services/autofill/java/com/android/server/autofill/ui/AutoFillUI.java @@ -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 diff --git a/services/autofill/java/com/android/server/autofill/ui/DialogFillUi.java b/services/autofill/java/com/android/server/autofill/ui/DialogFillUi.java new file mode 100644 index 0000000000000..e1229939e2cab --- /dev/null +++ b/services/autofill/java/com/android/server/autofill/ui/DialogFillUi.java @@ -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 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 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 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 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 mAllItems; + + private @NonNull final List mFilteredItems = new ArrayList<>(); + + ItemsAdapter(@NonNull List 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 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 items = + (List) 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(); + } + } +}