1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.server.autofill.ui;
18 
19 import static com.android.server.autofill.Helper.sDebug;
20 import static com.android.server.autofill.Helper.sVerbose;
21 
22 import android.annotation.NonNull;
23 import android.annotation.Nullable;
24 import android.app.Dialog;
25 import android.content.ComponentName;
26 import android.content.Context;
27 import android.content.IntentSender;
28 import android.graphics.drawable.Drawable;
29 import android.service.autofill.Dataset;
30 import android.service.autofill.FillResponse;
31 import android.text.TextUtils;
32 import android.util.DisplayMetrics;
33 import android.util.PluralsMessageFormatter;
34 import android.util.Slog;
35 import android.view.ContextThemeWrapper;
36 import android.view.Gravity;
37 import android.view.LayoutInflater;
38 import android.view.View;
39 import android.view.ViewGroup;
40 import android.view.Window;
41 import android.view.WindowManager;
42 import android.view.accessibility.AccessibilityManager;
43 import android.view.autofill.AutofillId;
44 import android.view.autofill.AutofillValue;
45 import android.widget.AdapterView;
46 import android.widget.BaseAdapter;
47 import android.widget.Filter;
48 import android.widget.Filterable;
49 import android.widget.ImageView;
50 import android.widget.ListView;
51 import android.widget.RemoteViews;
52 import android.widget.TextView;
53 
54 import com.android.internal.R;
55 import com.android.server.autofill.AutofillManagerService;
56 import com.android.server.autofill.Helper;
57 
58 import java.io.PrintWriter;
59 import java.util.ArrayList;
60 import java.util.Collections;
61 import java.util.HashMap;
62 import java.util.List;
63 import java.util.Map;
64 import java.util.regex.Pattern;
65 import java.util.stream.Collectors;
66 
67 /**
68  * A dialog to show Autofill suggestions.
69  *
70  * This fill dialog UI shows as a bottom sheet style dialog. This dialog UI
71  * provides a larger area to display the suggestions, it provides a more
72  * conspicuous and efficient interface to the user. So it is easy for users
73  * to pay attention to the datasets and selecting one of them.
74  */
75 final class DialogFillUi {
76 
77     private static final String TAG = "DialogFillUi";
78     private static final int THEME_ID_LIGHT =
79             R.style.Theme_DeviceDefault_Light_Autofill_Save;
80     private static final int THEME_ID_DARK =
81             R.style.Theme_DeviceDefault_Autofill_Save;
82 
83     interface UiCallback {
onResponsePicked(@onNull FillResponse response)84         void onResponsePicked(@NonNull FillResponse response);
onDatasetPicked(@onNull Dataset dataset)85         void onDatasetPicked(@NonNull Dataset dataset);
onDismissed()86         void onDismissed();
onCanceled()87         void onCanceled();
onShown()88         void onShown();
startIntentSender(IntentSender intentSender)89         void startIntentSender(IntentSender intentSender);
90     }
91 
92     private final @NonNull Dialog mDialog;
93     private final @NonNull OverlayControl mOverlayControl;
94     private final String mServicePackageName;
95     private final ComponentName mComponentName;
96     private final int mThemeId;
97     private final @NonNull Context mContext;
98     private final @NonNull UiCallback mCallback;
99     private final @NonNull ListView mListView;
100     private final @Nullable ItemsAdapter mAdapter;
101     private final int mVisibleDatasetsMaxCount;
102 
103     private @Nullable String mFilterText;
104     private @Nullable AnnounceFilterResult mAnnounceFilterResult;
105     private boolean mDestroyed;
106 
DialogFillUi(@onNull 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)107     DialogFillUi(@NonNull Context context, @NonNull FillResponse response,
108             @NonNull AutofillId focusedViewId, @Nullable String filterText,
109             @Nullable Drawable serviceIcon, @Nullable String servicePackageName,
110             @Nullable ComponentName componentName, @NonNull OverlayControl overlayControl,
111             boolean nightMode, @NonNull UiCallback callback) {
112         if (sVerbose) Slog.v(TAG, "nightMode: " + nightMode);
113         mThemeId = nightMode ? THEME_ID_DARK : THEME_ID_LIGHT;
114         mCallback = callback;
115         mOverlayControl = overlayControl;
116         mServicePackageName = servicePackageName;
117         mComponentName = componentName;
118 
119         mContext = new ContextThemeWrapper(context, mThemeId);
120         final LayoutInflater inflater = LayoutInflater.from(mContext);
121         final View decor = inflater.inflate(R.layout.autofill_fill_dialog, null);
122 
123         if (response.getShowFillDialogIcon()) {
124             setServiceIcon(decor, serviceIcon);
125         }
126         setHeader(decor, response);
127 
128         mVisibleDatasetsMaxCount = getVisibleDatasetsMaxCount();
129 
130         if (response.getAuthentication() != null) {
131             mListView = null;
132             mAdapter = null;
133             try {
134                 initialAuthenticationLayout(decor, response);
135             } catch (RuntimeException e) {
136                 callback.onCanceled();
137                 Slog.e(TAG, "Error inflating remote views", e);
138                 mDialog = null;
139                 return;
140             }
141         } else {
142             final List<ViewItem> items = createDatasetItems(response, focusedViewId);
143             mAdapter = new ItemsAdapter(items);
144             mListView = decor.findViewById(R.id.autofill_dialog_list);
145             initialDatasetLayout(decor, filterText);
146         }
147 
148         setDismissButton(decor);
149 
150         mDialog = new Dialog(mContext, mThemeId);
151         mDialog.setContentView(decor);
152         setDialogParamsAsBottomSheet();
153         mDialog.setOnCancelListener((d) -> mCallback.onCanceled());
154         mDialog.setOnShowListener((d) -> mCallback.onShown());
155         show();
156     }
157 
getVisibleDatasetsMaxCount()158     private int getVisibleDatasetsMaxCount() {
159         if (AutofillManagerService.getVisibleDatasetsMaxCount() > 0) {
160             final int maxCount = AutofillManagerService.getVisibleDatasetsMaxCount();
161             if (sVerbose) {
162                 Slog.v(TAG, "overriding maximum visible datasets to " + maxCount);
163             }
164             return maxCount;
165         } else {
166             return mContext.getResources()
167                     .getInteger(com.android.internal.R.integer.autofill_max_visible_datasets);
168         }
169     }
170 
setDialogParamsAsBottomSheet()171     private void setDialogParamsAsBottomSheet() {
172         final Window window = mDialog.getWindow();
173         window.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
174         window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
175                 | WindowManager.LayoutParams.FLAG_DIM_BEHIND);
176         window.setDimAmount(0.6f);
177         window.addPrivateFlags(WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS);
178         window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN);
179         window.setGravity(Gravity.BOTTOM | Gravity.CENTER);
180         window.setCloseOnTouchOutside(true);
181         final WindowManager.LayoutParams params = window.getAttributes();
182 
183         DisplayMetrics displayMetrics = new DisplayMetrics();
184         window.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
185         final int screenWidth = displayMetrics.widthPixels;
186         final int maxWidth =
187                 mContext.getResources().getDimensionPixelSize(R.dimen.autofill_dialog_max_width);
188         params.width = Math.min(screenWidth, maxWidth);
189 
190         params.accessibilityTitle =
191                 mContext.getString(R.string.autofill_picker_accessibility_title);
192         params.windowAnimations = R.style.AutofillSaveAnimation;
193     }
194 
setServiceIcon(View decor, Drawable serviceIcon)195     private void setServiceIcon(View decor, Drawable serviceIcon) {
196         if (serviceIcon == null) {
197             return;
198         }
199 
200         final ImageView iconView = decor.findViewById(R.id.autofill_service_icon);
201         final int actualWidth = serviceIcon.getMinimumWidth();
202         final int actualHeight = serviceIcon.getMinimumHeight();
203         if (sDebug) {
204             Slog.d(TAG, "Adding service icon "
205                     + "(" + actualWidth + "x" + actualHeight + ")");
206         }
207         iconView.setImageDrawable(serviceIcon);
208         iconView.setVisibility(View.VISIBLE);
209     }
210 
setHeader(View decor, FillResponse response)211     private void setHeader(View decor, FillResponse response) {
212         final RemoteViews presentation =
213                 Helper.sanitizeRemoteView(response.getDialogHeader());
214         if (presentation == null) {
215             return;
216         }
217 
218         final ViewGroup container = decor.findViewById(R.id.autofill_dialog_header);
219         final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> {
220             if (pendingIntent != null) {
221                 mCallback.startIntentSender(pendingIntent.getIntentSender());
222             }
223             return true;
224         };
225 
226         final View content = presentation.applyWithTheme(
227                 mContext, (ViewGroup) decor, interceptionHandler, mThemeId);
228         container.addView(content);
229         container.setVisibility(View.VISIBLE);
230     }
231 
setDismissButton(View decor)232     private void setDismissButton(View decor) {
233         final TextView noButton = decor.findViewById(R.id.autofill_dialog_no);
234         // set "No thinks" by default
235         noButton.setText(R.string.autofill_save_no);
236         noButton.setOnClickListener((v) -> mCallback.onDismissed());
237     }
238 
setContinueButton(View decor, View.OnClickListener listener)239     private void setContinueButton(View decor, View.OnClickListener listener) {
240         final TextView yesButton = decor.findViewById(R.id.autofill_dialog_yes);
241         // set "Continue" by default
242         yesButton.setText(R.string.autofill_continue_yes);
243         yesButton.setOnClickListener(listener);
244         yesButton.setVisibility(View.VISIBLE);
245     }
246 
initialAuthenticationLayout(View decor, FillResponse response)247     private void initialAuthenticationLayout(View decor, FillResponse response) {
248         RemoteViews presentation = Helper.sanitizeRemoteView(
249                 response.getDialogPresentation());
250         if (presentation == null) {
251             presentation = Helper.sanitizeRemoteView(response.getPresentation());
252         }
253         if (presentation == null) {
254             throw new RuntimeException("No presentation for fill dialog authentication");
255         }
256 
257         // insert authentication item under autofill_dialog_container
258         final ViewGroup container = decor.findViewById(R.id.autofill_dialog_container);
259         final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> {
260             if (pendingIntent != null) {
261                 mCallback.startIntentSender(pendingIntent.getIntentSender());
262             }
263             return true;
264         };
265         final View content = presentation.applyWithTheme(
266                 mContext, (ViewGroup) decor, interceptionHandler, mThemeId);
267         container.addView(content);
268         container.setVisibility(View.VISIBLE);
269         container.setFocusable(true);
270         container.setOnClickListener(v -> mCallback.onResponsePicked(response));
271         // just single item, set up continue button
272         setContinueButton(decor, v -> mCallback.onResponsePicked(response));
273     }
274 
createDatasetItems(FillResponse response, AutofillId focusedViewId)275     private ArrayList<ViewItem> createDatasetItems(FillResponse response,
276             AutofillId focusedViewId) {
277         final int datasetCount = response.getDatasets().size();
278         if (sVerbose) {
279             Slog.v(TAG, "Number datasets: " + datasetCount + " max visible: "
280                     + mVisibleDatasetsMaxCount);
281         }
282 
283         final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> {
284             if (pendingIntent != null) {
285                 mCallback.startIntentSender(pendingIntent.getIntentSender());
286             }
287             return true;
288         };
289 
290         final ArrayList<ViewItem> items = new ArrayList<>(datasetCount);
291         for (int i = 0; i < datasetCount; i++) {
292             final Dataset dataset = response.getDatasets().get(i);
293             final int index = dataset.getFieldIds().indexOf(focusedViewId);
294             if (index >= 0) {
295                 RemoteViews presentation = Helper.sanitizeRemoteView(
296                         dataset.getFieldDialogPresentation(index));
297                 if (presentation == null) {
298                     if (sDebug) {
299                         Slog.w(TAG, "not displaying UI on field " + focusedViewId + " because "
300                                 + "service didn't provide a presentation for it on " + dataset);
301                     }
302                     continue;
303                 }
304                 final View view;
305                 try {
306                     if (sVerbose) Slog.v(TAG, "setting remote view for " + focusedViewId);
307                     view = presentation.applyWithTheme(
308                             mContext, null, interceptionHandler, mThemeId);
309                 } catch (RuntimeException e) {
310                     Slog.e(TAG, "Error inflating remote views", e);
311                     continue;
312                 }
313                 // TODO: Extract the shared filtering logic here and in FillUi to a common
314                 //  method.
315                 final Dataset.DatasetFieldFilter filter = dataset.getFilter(index);
316                 Pattern filterPattern = null;
317                 String valueText = null;
318                 boolean filterable = true;
319                 if (filter == null) {
320                     final AutofillValue value = dataset.getFieldValues().get(index);
321                     if (value != null && value.isText()) {
322                         valueText = value.getTextValue().toString().toLowerCase();
323                     }
324                 } else {
325                     filterPattern = filter.pattern;
326                     if (filterPattern == null) {
327                         if (sVerbose) {
328                             Slog.v(TAG, "Explicitly disabling filter at id " + focusedViewId
329                                     + " for dataset #" + index);
330                         }
331                         filterable = false;
332                     }
333                 }
334 
335                 items.add(new ViewItem(dataset, filterPattern, filterable, valueText, view));
336             }
337         }
338         return items;
339     }
340 
initialDatasetLayout(View decor, String filterText)341     private void initialDatasetLayout(View decor, String filterText) {
342         final AdapterView.OnItemClickListener onItemClickListener =
343                 (adapter, view, position, id) -> {
344                     final ViewItem vi = mAdapter.getItem(position);
345                     mCallback.onDatasetPicked(vi.dataset);
346                 };
347 
348         mListView.setAdapter(mAdapter);
349         mListView.setVisibility(View.VISIBLE);
350         mListView.setOnItemClickListener(onItemClickListener);
351 
352         if (mAdapter.getCount() == 1) {
353             // just single item, set up continue button
354             setContinueButton(decor, (v) ->
355                     onItemClickListener.onItemClick(null, null, 0, 0));
356         }
357 
358         if (filterText == null) {
359             mFilterText = null;
360         } else {
361             mFilterText = filterText.toLowerCase();
362         }
363 
364         final int oldCount = mAdapter.getCount();
365         mAdapter.getFilter().filter(mFilterText, (count) -> {
366             if (mDestroyed) {
367                 return;
368             }
369             if (count <= 0) {
370                 if (sDebug) {
371                     final int size = mFilterText == null ? 0 : mFilterText.length();
372                     Slog.d(TAG, "No dataset matches filter with " + size + " chars");
373                 }
374                 mCallback.onCanceled();
375             } else {
376 
377                 if (mAdapter.getCount() > mVisibleDatasetsMaxCount) {
378                     mListView.setVerticalScrollBarEnabled(true);
379                     mListView.onVisibilityAggregated(true);
380                 } else {
381                     mListView.setVerticalScrollBarEnabled(false);
382                 }
383                 if (mAdapter.getCount() != oldCount) {
384                     mListView.requestLayout();
385                 }
386             }
387         });
388     }
389 
show()390     private void show() {
391         Slog.i(TAG, "Showing fill dialog");
392         mDialog.show();
393         mOverlayControl.hideOverlays();
394     }
395 
isShowing()396     boolean isShowing() {
397         return mDialog.isShowing();
398     }
399 
hide()400     void hide() {
401         if (sVerbose) Slog.v(TAG, "Hiding fill dialog.");
402         try {
403             mDialog.hide();
404         } finally {
405             mOverlayControl.showOverlays();
406         }
407     }
408 
destroy()409     void destroy() {
410         try {
411             if (sDebug) Slog.d(TAG, "destroy()");
412             throwIfDestroyed();
413 
414             mDialog.dismiss();
415             mDestroyed = true;
416         } finally {
417             mOverlayControl.showOverlays();
418         }
419     }
420 
throwIfDestroyed()421     private void throwIfDestroyed() {
422         if (mDestroyed) {
423             throw new IllegalStateException("cannot interact with a destroyed instance");
424         }
425     }
426 
427     @Override
toString()428     public String toString() {
429         // TODO toString
430         return "NO TITLE";
431     }
432 
dump(PrintWriter pw, String prefix)433     void dump(PrintWriter pw, String prefix) {
434 
435         pw.print(prefix); pw.print("service: "); pw.println(mServicePackageName);
436         pw.print(prefix); pw.print("app: "); pw.println(mComponentName.toShortString());
437         pw.print(prefix); pw.print("theme id: "); pw.print(mThemeId);
438         switch (mThemeId) {
439             case THEME_ID_DARK:
440                 pw.println(" (dark)");
441                 break;
442             case THEME_ID_LIGHT:
443                 pw.println(" (light)");
444                 break;
445             default:
446                 pw.println("(UNKNOWN_MODE)");
447                 break;
448         }
449         final View view = mDialog.getWindow().getDecorView();
450         final int[] loc = view.getLocationOnScreen();
451         pw.print(prefix); pw.print("coordinates: ");
452             pw.print('('); pw.print(loc[0]); pw.print(','); pw.print(loc[1]); pw.print(')');
453             pw.print('(');
454                 pw.print(loc[0] + view.getWidth()); pw.print(',');
455                 pw.print(loc[1] + view.getHeight()); pw.println(')');
456         pw.print(prefix); pw.print("destroyed: "); pw.println(mDestroyed);
457     }
458 
announceSearchResultIfNeeded()459     private void announceSearchResultIfNeeded() {
460         if (AccessibilityManager.getInstance(mContext).isEnabled()) {
461             if (mAnnounceFilterResult == null) {
462                 mAnnounceFilterResult = new AnnounceFilterResult();
463             }
464             mAnnounceFilterResult.post();
465         }
466     }
467 
468     // TODO: Below code copied from FullUi, Extract the shared filtering logic here
469     // and in FillUi to a common method.
470     private final class AnnounceFilterResult implements Runnable {
471         private static final int SEARCH_RESULT_ANNOUNCEMENT_DELAY = 1000; // 1 sec
472 
post()473         public void post() {
474             remove();
475             mListView.postDelayed(this, SEARCH_RESULT_ANNOUNCEMENT_DELAY);
476         }
477 
remove()478         public void remove() {
479             mListView.removeCallbacks(this);
480         }
481 
482         @Override
run()483         public void run() {
484             final int count = mListView.getAdapter().getCount();
485             final String text;
486             if (count <= 0) {
487                 text = mContext.getString(R.string.autofill_picker_no_suggestions);
488             } else {
489                 Map<String, Object> arguments = new HashMap<>();
490                 arguments.put("count", count);
491                 text = PluralsMessageFormatter.format(mContext.getResources(),
492                         arguments,
493                         R.string.autofill_picker_some_suggestions);
494             }
495             mListView.announceForAccessibility(text);
496         }
497     }
498 
499     private final class ItemsAdapter extends BaseAdapter implements Filterable {
500         private @NonNull final List<ViewItem> mAllItems;
501 
502         private @NonNull final List<ViewItem> mFilteredItems = new ArrayList<>();
503 
ItemsAdapter(@onNull List<ViewItem> items)504         ItemsAdapter(@NonNull List<ViewItem> items) {
505             mAllItems = Collections.unmodifiableList(new ArrayList<>(items));
506             mFilteredItems.addAll(items);
507         }
508 
509         @Override
getFilter()510         public Filter getFilter() {
511             return new Filter() {
512                 @Override
513                 protected FilterResults performFiltering(CharSequence filterText) {
514                     // No locking needed as mAllItems is final an immutable
515                     final List<ViewItem> filtered = mAllItems.stream()
516                             .filter((item) -> item.matches(filterText))
517                             .collect(Collectors.toList());
518                     final FilterResults results = new FilterResults();
519                     results.values = filtered;
520                     results.count = filtered.size();
521                     return results;
522                 }
523 
524                 @Override
525                 protected void publishResults(CharSequence constraint, FilterResults results) {
526                     final boolean resultCountChanged;
527                     final int oldItemCount = mFilteredItems.size();
528                     mFilteredItems.clear();
529                     if (results.count > 0) {
530                         @SuppressWarnings("unchecked") final List<ViewItem> items =
531                                 (List<ViewItem>) results.values;
532                         mFilteredItems.addAll(items);
533                     }
534                     resultCountChanged = (oldItemCount != mFilteredItems.size());
535                     if (resultCountChanged) {
536                         announceSearchResultIfNeeded();
537                     }
538                     notifyDataSetChanged();
539                 }
540             };
541         }
542 
543         @Override
getCount()544         public int getCount() {
545             return mFilteredItems.size();
546         }
547 
548         @Override
getItem(int position)549         public ViewItem getItem(int position) {
550             return mFilteredItems.get(position);
551         }
552 
553         @Override
getItemId(int position)554         public long getItemId(int position) {
555             return position;
556         }
557 
558         @Override
getView(int position, View convertView, ViewGroup parent)559         public View getView(int position, View convertView, ViewGroup parent) {
560             return getItem(position).view;
561         }
562 
563         @Override
toString()564         public String toString() {
565             return "ItemsAdapter: [all=" + mAllItems + ", filtered=" + mFilteredItems + "]";
566         }
567     }
568 
569 
570     /**
571      * An item for the list view - either a (clickable) dataset or a (read-only) header / footer.
572      */
573     private static class ViewItem {
574         public final @Nullable String value;
575         public final @Nullable Dataset dataset;
576         public final @NonNull View view;
577         public final @Nullable Pattern filter;
578         public final boolean filterable;
579 
580         /**
581          * Default constructor.
582          *
583          * @param dataset dataset associated with the item
584          * @param filter optional filter set by the service to determine how the item should be
585          * filtered
586          * @param filterable optional flag set by the service to indicate this item should not be
587          * filtered (typically used when the dataset has value but it's sensitive, like a password)
588          * @param value dataset value
589          * @param view dataset presentation.
590          */
591         ViewItem(@NonNull Dataset dataset, @Nullable Pattern filter, boolean filterable,
592                 @Nullable String value, @NonNull View view) {
593             this.dataset = dataset;
594             this.value = value;
595             this.view = view;
596             this.filter = filter;
597             this.filterable = filterable;
598         }
599 
600         /**
601          * Returns whether this item matches the value input by the user so it can be included
602          * in the filtered datasets.
603          */
604         public boolean matches(CharSequence filterText) {
605             if (TextUtils.isEmpty(filterText)) {
606                 // Always show item when the user input is empty
607                 return true;
608             }
609             if (!filterable) {
610                 // Service explicitly disabled filtering using a null Pattern.
611                 return false;
612             }
613             final String constraintLowerCase = filterText.toString().toLowerCase();
614             if (filter != null) {
615                 // Uses pattern provided by service
616                 return filter.matcher(constraintLowerCase).matches();
617             } else {
618                 // Compares it with dataset value with dataset
619                 return (value == null)
620                         ? (dataset.getAuthentication() == null)
621                         : value.toLowerCase().startsWith(constraintLowerCase);
622             }
623         }
624 
625         @Override
626         public String toString() {
627             final StringBuilder builder = new StringBuilder("ViewItem:[view=")
628                     .append(view.getAutofillId());
629             final String datasetId = dataset == null ? null : dataset.getId();
630             if (datasetId != null) {
631                 builder.append(", dataset=").append(datasetId);
632             }
633             if (value != null) {
634                 // Cannot print value because it could contain PII
635                 builder.append(", value=").append(value.length()).append("_chars");
636             }
637             if (filterable) {
638                 builder.append(", filterable");
639             }
640             if (filter != null) {
641                 // Filter should not have PII, but it could be a huge regexp
642                 builder.append(", filter=").append(filter.pattern().length()).append("_chars");
643             }
644             return builder.append(']').toString();
645         }
646     }
647 }
648