• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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