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