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