FillUi.java revision 2ee821b6757ffc07b474c254b50e3d45e774172f
1/*
2 * Copyright (C) 2017 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 */
16package com.android.server.autofill.ui;
17
18import static com.android.server.autofill.Helper.sDebug;
19import static com.android.server.autofill.Helper.sVerbose;
20
21import android.annotation.NonNull;
22import android.annotation.Nullable;
23import android.app.PendingIntent;
24import android.content.Context;
25import android.content.Intent;
26import android.content.IntentSender;
27import android.graphics.Point;
28import android.graphics.Rect;
29import android.service.autofill.Dataset;
30import android.service.autofill.FillResponse;
31import android.text.TextUtils;
32import android.util.Slog;
33import android.util.TypedValue;
34import android.view.LayoutInflater;
35import android.view.MotionEvent;
36import android.view.View;
37import android.view.View.MeasureSpec;
38import android.view.ViewGroup;
39import android.view.WindowManager;
40import android.view.accessibility.AccessibilityManager;
41import android.view.autofill.AutofillId;
42import android.view.autofill.AutofillValue;
43import android.view.autofill.IAutofillWindowPresenter;
44import android.widget.BaseAdapter;
45import android.widget.Filter;
46import android.widget.Filterable;
47import android.widget.ListView;
48import android.widget.RemoteViews;
49
50import com.android.internal.R;
51import com.android.server.UiThread;
52import libcore.util.Objects;
53
54import java.io.PrintWriter;
55import java.util.ArrayList;
56import java.util.Collections;
57import java.util.List;
58
59final class FillUi {
60    private static final String TAG = "FillUi";
61
62    private static final int VISIBLE_OPTIONS_MAX_COUNT = 3;
63
64    private static final TypedValue sTempTypedValue = new TypedValue();
65
66    interface Callback {
67        void onResponsePicked(@NonNull FillResponse response);
68        void onDatasetPicked(@NonNull Dataset dataset);
69        void onCanceled();
70        void onDestroy();
71        void requestShowFillUi(int width, int height,
72                IAutofillWindowPresenter windowPresenter);
73        void requestHideFillUi();
74        void startIntentSender(IntentSender intentSender);
75    }
76
77    private final @NonNull Point mTempPoint = new Point();
78
79    private final @NonNull AutofillWindowPresenter mWindowPresenter =
80            new AutofillWindowPresenter();
81
82    private final @NonNull Context mContext;
83
84    private final @NonNull AnchoredWindow mWindow;
85
86    private final @NonNull Callback mCallback;
87
88    private final @NonNull ListView mListView;
89
90    private final @Nullable ItemsAdapter mAdapter;
91
92    private @Nullable String mFilterText;
93
94    private @Nullable AnnounceFilterResult mAnnounceFilterResult;
95
96    private int mContentWidth;
97    private int mContentHeight;
98
99    private boolean mDestroyed;
100
101    FillUi(@NonNull Context context, @NonNull FillResponse response,
102            @NonNull AutofillId focusedViewId, @NonNull @Nullable String filterText,
103            @NonNull OverlayControl overlayControl, @NonNull Callback callback) {
104        mContext = context;
105        mCallback = callback;
106
107        final LayoutInflater inflater = LayoutInflater.from(context);
108        final ViewGroup decor = (ViewGroup) inflater.inflate(
109                R.layout.autofill_dataset_picker, null);
110
111        final RemoteViews.OnClickHandler interceptionHandler = new RemoteViews.OnClickHandler() {
112            @Override
113            public boolean onClickHandler(View view, PendingIntent pendingIntent,
114                    Intent fillInIntent) {
115                if (pendingIntent != null) {
116                    mCallback.startIntentSender(pendingIntent.getIntentSender());
117                }
118                return true;
119            }
120        };
121
122        if (response.getAuthentication() != null) {
123            mListView = null;
124            mAdapter = null;
125
126            final View content;
127            try {
128                content = response.getPresentation().apply(context, decor, interceptionHandler);
129                decor.addView(content);
130            } catch (RuntimeException e) {
131                callback.onCanceled();
132                Slog.e(TAG, "Error inflating remote views", e);
133                mWindow = null;
134                return;
135            }
136
137            Point maxSize = mTempPoint;
138            resolveMaxWindowSize(context, maxSize);
139            final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(maxSize.x,
140                    MeasureSpec.AT_MOST);
141            final int heightMeasureSpec = MeasureSpec.makeMeasureSpec(maxSize.y,
142                    MeasureSpec.AT_MOST);
143
144            decor.measure(widthMeasureSpec, heightMeasureSpec);
145            decor.setOnClickListener(v -> mCallback.onResponsePicked(response));
146            mContentWidth = content.getMeasuredWidth();
147            mContentHeight = content.getMeasuredHeight();
148
149            mWindow = new AnchoredWindow(decor, overlayControl);
150            mCallback.requestShowFillUi(mContentWidth, mContentHeight, mWindowPresenter);
151        } else {
152            final int datasetCount = response.getDatasets().size();
153            final ArrayList<ViewItem> items = new ArrayList<>(datasetCount);
154            for (int i = 0; i < datasetCount; i++) {
155                final Dataset dataset = response.getDatasets().get(i);
156                final int index = dataset.getFieldIds().indexOf(focusedViewId);
157                if (index >= 0) {
158                    final RemoteViews presentation = dataset.getFieldPresentation(index);
159                    final View view;
160                    try {
161                        if (sVerbose) Slog.v(TAG, "setting remote view for " + focusedViewId);
162                        view = presentation.apply(context, null, interceptionHandler);
163                    } catch (RuntimeException e) {
164                        Slog.e(TAG, "Error inflating remote views", e);
165                        continue;
166                    }
167                    final AutofillValue value = dataset.getFieldValues().get(index);
168                    String valueText = null;
169                    // If the dataset needs auth - don't add its text to allow guessing
170                    // its content based on how filtering behaves.
171                    if (value != null && value.isText() && dataset.getAuthentication() == null) {
172                        valueText = value.getTextValue().toString().toLowerCase();
173                    }
174
175                    items.add(new ViewItem(dataset, valueText, view));
176                }
177            }
178
179            mAdapter = new ItemsAdapter(items);
180
181            mListView = decor.findViewById(R.id.autofill_dataset_list);
182            mListView.setAdapter(mAdapter);
183            mListView.setVisibility(View.VISIBLE);
184            mListView.setOnItemClickListener((adapter, view, position, id) -> {
185                final ViewItem vi = mAdapter.getItem(position);
186                mCallback.onDatasetPicked(vi.getDataset());
187            });
188
189            if (filterText == null) {
190                mFilterText = null;
191            } else {
192                mFilterText = filterText.toLowerCase();
193            }
194
195            applyNewFilterText();
196            mWindow = new AnchoredWindow(decor, overlayControl);
197        }
198    }
199
200    private void applyNewFilterText() {
201        final int oldCount = mAdapter.getCount();
202        mAdapter.getFilter().filter(mFilterText, (count) -> {
203            if (mDestroyed) {
204                return;
205            }
206            if (count <= 0) {
207                if (sDebug) Slog.d(TAG, "No dataset matches filter: " + mFilterText);
208                mCallback.requestHideFillUi();
209            } else {
210                if (updateContentSize()) {
211                    mCallback.requestShowFillUi(mContentWidth, mContentHeight, mWindowPresenter);
212                }
213                if (mAdapter.getCount() > VISIBLE_OPTIONS_MAX_COUNT) {
214                    mListView.setVerticalScrollBarEnabled(true);
215                    mListView.onVisibilityAggregated(true);
216                } else {
217                    mListView.setVerticalScrollBarEnabled(false);
218                }
219                if (mAdapter.getCount() != oldCount) {
220                    mListView.requestLayout();
221                }
222            }
223        });
224    }
225
226    public void setFilterText(@Nullable String filterText) {
227        throwIfDestroyed();
228        if (mAdapter == null) {
229            return;
230        }
231
232        if (filterText == null) {
233            filterText = null;
234        } else {
235            filterText = filterText.toLowerCase();
236        }
237
238        if (Objects.equal(mFilterText, filterText)) {
239            return;
240        }
241        mFilterText = filterText;
242
243        applyNewFilterText();
244    }
245
246    public void destroy() {
247        throwIfDestroyed();
248        mCallback.onDestroy();
249        mCallback.requestHideFillUi();
250        mDestroyed = true;
251    }
252
253    private boolean updateContentSize() {
254        if (mAdapter == null) {
255            return false;
256        }
257        boolean changed = false;
258        if (mAdapter.getCount() <= 0) {
259            if (mContentWidth != 0) {
260                mContentWidth = 0;
261                changed = true;
262            }
263            if (mContentHeight != 0) {
264                mContentHeight = 0;
265                changed = true;
266            }
267            return changed;
268        }
269
270        Point maxSize = mTempPoint;
271        resolveMaxWindowSize(mContext, maxSize);
272
273        mContentWidth = 0;
274        mContentHeight = 0;
275
276        final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(maxSize.x,
277                MeasureSpec.AT_MOST);
278        final int heightMeasureSpec = MeasureSpec.makeMeasureSpec(maxSize.y,
279                MeasureSpec.AT_MOST);
280        final int itemCount = mAdapter.getCount();
281        for (int i = 0; i < itemCount; i++) {
282            View view = mAdapter.getItem(i).getView();
283            view.measure(widthMeasureSpec, heightMeasureSpec);
284            final int clampedMeasuredWidth = Math.min(view.getMeasuredWidth(), maxSize.x);
285            final int newContentWidth = Math.max(mContentWidth, clampedMeasuredWidth);
286            if (newContentWidth != mContentWidth) {
287                mContentWidth = newContentWidth;
288                changed = true;
289            }
290            // Update the width to fit only the first items up to max count
291            if (i < VISIBLE_OPTIONS_MAX_COUNT) {
292                final int clampedMeasuredHeight = Math.min(view.getMeasuredHeight(), maxSize.y);
293                final int newContentHeight = mContentHeight + clampedMeasuredHeight;
294                if (newContentHeight != mContentHeight) {
295                    mContentHeight = newContentHeight;
296                    changed = true;
297                }
298            }
299        }
300        return changed;
301    }
302
303    private void throwIfDestroyed() {
304        if (mDestroyed) {
305            throw new IllegalStateException("cannot interact with a destroyed instance");
306        }
307    }
308
309    private static void resolveMaxWindowSize(Context context, Point outPoint) {
310        context.getDisplay().getSize(outPoint);
311        TypedValue typedValue = sTempTypedValue;
312        context.getTheme().resolveAttribute(R.attr.autofillDatasetPickerMaxWidth,
313                typedValue, true);
314        outPoint.x = (int) typedValue.getFraction(outPoint.x, outPoint.x);
315        context.getTheme().resolveAttribute(R.attr.autofillDatasetPickerMaxHeight,
316                typedValue, true);
317        outPoint.y = (int) typedValue.getFraction(outPoint.y, outPoint.y);
318    }
319
320    private static class ViewItem {
321        private final String mValue;
322        private final Dataset mDataset;
323        private final View mView;
324
325        ViewItem(Dataset dataset, String value, View view) {
326            mDataset = dataset;
327            mValue = value;
328            mView = view;
329        }
330
331        public View getView() {
332            return mView;
333        }
334
335        public Dataset getDataset() {
336            return mDataset;
337        }
338
339        public String getValue() {
340            return mValue;
341        }
342
343        @Override
344        public String toString() {
345            // Used for filtering in the adapter
346            return mValue;
347        }
348    }
349
350    private final class AutofillWindowPresenter extends IAutofillWindowPresenter.Stub {
351        @Override
352        public void show(WindowManager.LayoutParams p, Rect transitionEpicenter,
353                boolean fitsSystemWindows, int layoutDirection) {
354            if (sVerbose) {
355                Slog.v(TAG, "AutofillWindowPresenter.show(): fit=" + fitsSystemWindows
356                        + ", epicenter="+ transitionEpicenter + ", dir=" + layoutDirection
357                        + ", params=" + p);
358            }
359            UiThread.getHandler().post(() -> mWindow.show(p));
360        }
361
362        @Override
363        public void hide(Rect transitionEpicenter) {
364            UiThread.getHandler().post(mWindow::hide);
365        }
366    }
367
368    final class AnchoredWindow implements View.OnTouchListener {
369        private final @NonNull OverlayControl mOverlayControl;
370        private final WindowManager mWm;
371        private final View mContentView;
372        private boolean mShowing;
373
374        /**
375         * Constructor.
376         *
377         * @param contentView content of the window
378         */
379        AnchoredWindow(View contentView, @NonNull OverlayControl overlayControl) {
380            mWm = contentView.getContext().getSystemService(WindowManager.class);
381            mContentView = contentView;
382            mOverlayControl = overlayControl;
383        }
384
385        /**
386         * Shows the window.
387         */
388        public void show(WindowManager.LayoutParams params) {
389            if (sVerbose) Slog.v(TAG, "show(): showing=" + mShowing + ", params="+  params);
390            try {
391                if (!mShowing) {
392                    params.accessibilityTitle = mContentView.getContext()
393                            .getString(R.string.autofill_picker_accessibility_title);
394                    mWm.addView(mContentView, params);
395                    mContentView.setOnTouchListener(this);
396                    mOverlayControl.hideOverlays();
397                    mShowing = true;
398                } else {
399                    mWm.updateViewLayout(mContentView, params);
400                }
401            } catch (WindowManager.BadTokenException e) {
402                if (sDebug) Slog.d(TAG, "Filed with with token " + params.token + " gone.");
403                mCallback.onDestroy();
404            } catch (IllegalStateException e) {
405                // WM throws an ISE if mContentView was added twice; this should never happen -
406                // since show() and hide() are always called in the UIThread - but when it does,
407                // it should not crash the system.
408                Slog.e(TAG, "Exception showing window " + params, e);
409                mCallback.onDestroy();
410            }
411        }
412
413        /**
414         * Hides the window.
415         */
416        void hide() {
417            try {
418                if (mShowing) {
419                    mContentView.setOnTouchListener(null);
420                    mWm.removeView(mContentView);
421                    mShowing = false;
422                }
423            } catch (IllegalStateException e) {
424                // WM might thrown an ISE when removing the mContentView; this should never
425                // happen - since show() and hide() are always called in the UIThread - but if it
426                // does, it should not crash the system.
427                Slog.e(TAG, "Exception hiding window ", e);
428                mCallback.onDestroy();
429            } finally {
430                mOverlayControl.showOverlays();
431            }
432        }
433
434        @Override
435        public boolean onTouch(View view, MotionEvent event) {
436            // When the window is touched outside, hide the window.
437            if (view == mContentView && event.getAction() == MotionEvent.ACTION_OUTSIDE) {
438                mCallback.onCanceled();
439                return true;
440            }
441            return false;
442        }
443
444    }
445
446    public void dump(PrintWriter pw, String prefix) {
447        pw.print(prefix); pw.print("mCallback: "); pw.println(mCallback != null);
448        pw.print(prefix); pw.print("mListView: "); pw.println(mListView);
449        pw.print(prefix); pw.print("mAdapter: "); pw.println(mAdapter != null);
450        pw.print(prefix); pw.print("mFilterText: "); pw.println(mFilterText);
451        pw.print(prefix); pw.print("mContentWidth: "); pw.println(mContentWidth);
452        pw.print(prefix); pw.print("mContentHeight: "); pw.println(mContentHeight);
453        pw.print(prefix); pw.print("mDestroyed: "); pw.println(mDestroyed);
454        pw.print(prefix); pw.print("mWindow: ");
455        if (mWindow == null) {
456            pw.println("N/A");
457        } else {
458            final String prefix2 = prefix + "  ";
459            pw.println();
460            pw.print(prefix2); pw.print("showing: "); pw.println(mWindow.mShowing);
461            pw.print(prefix2); pw.print("view: "); pw.println(mWindow.mContentView);
462            pw.print(prefix2); pw.print("screen coordinates: ");
463            if (mWindow.mContentView == null) {
464                pw.println("N/A");
465            } else {
466                final int[] coordinates = mWindow.mContentView.getLocationOnScreen();
467                pw.print(coordinates[0]); pw.print("x"); pw.println(coordinates[1]);
468            }
469        }
470    }
471
472    private void announceSearchResultIfNeeded() {
473        if (AccessibilityManager.getInstance(mContext).isEnabled()) {
474            if (mAnnounceFilterResult == null) {
475                mAnnounceFilterResult = new AnnounceFilterResult();
476            }
477            mAnnounceFilterResult.post();
478        }
479    }
480
481    private final class ItemsAdapter extends BaseAdapter implements Filterable {
482        private @NonNull final List<ViewItem> mAllItems;
483
484        private @NonNull final List<ViewItem> mFilteredItems = new ArrayList<>();
485
486        ItemsAdapter(@NonNull List<ViewItem> items) {
487            mAllItems = Collections.unmodifiableList(new ArrayList<>(items));
488            mFilteredItems.addAll(items);
489        }
490
491        @Override
492        public Filter getFilter() {
493            return new Filter() {
494                @Override
495                protected FilterResults performFiltering(CharSequence constraint) {
496                    // No locking needed as mAllItems is final an immutable
497                    final FilterResults results = new FilterResults();
498                    if (TextUtils.isEmpty(constraint)) {
499                        results.values = mAllItems;
500                        results.count = mAllItems.size();
501                        return results;
502                    }
503                    final List<ViewItem> filteredItems = new ArrayList<>();
504                    final String constraintLowerCase = constraint.toString().toLowerCase();
505                    final int itemCount = mAllItems.size();
506                    for (int i = 0; i < itemCount; i++) {
507                        final ViewItem item = mAllItems.get(i);
508                        final String value = item.getValue();
509                        // No value, i.e. null, matches any filter
510                        if (value == null
511                                || value.toLowerCase().startsWith(constraintLowerCase)) {
512                            filteredItems.add(item);
513                        }
514                    }
515                    results.values = filteredItems;
516                    results.count = filteredItems.size();
517                    return results;
518                }
519
520                @Override
521                protected void publishResults(CharSequence constraint, FilterResults results) {
522                    final boolean resultCountChanged;
523                    final int oldItemCount = mFilteredItems.size();
524                    mFilteredItems.clear();
525                    @SuppressWarnings("unchecked")
526                    final List<ViewItem> items = (List<ViewItem>) results.values;
527                    mFilteredItems.addAll(items);
528                    resultCountChanged = (oldItemCount != mFilteredItems.size());
529                    if (resultCountChanged) {
530                        announceSearchResultIfNeeded();
531                    }
532                    notifyDataSetChanged();
533                }
534            };
535        }
536
537        @Override
538        public int getCount() {
539            return mFilteredItems.size();
540        }
541
542        @Override
543        public ViewItem getItem(int position) {
544            return mFilteredItems.get(position);
545        }
546
547        @Override
548        public long getItemId(int position) {
549            return position;
550        }
551
552        @Override
553        public View getView(int position, View convertView, ViewGroup parent) {
554            return getItem(position).getView();
555        }
556    }
557
558    private final class AnnounceFilterResult implements Runnable {
559        private static final int SEARCH_RESULT_ANNOUNCEMENT_DELAY = 1000; // 1 sec
560
561        public void post() {
562            remove();
563            mListView.postDelayed(this, SEARCH_RESULT_ANNOUNCEMENT_DELAY);
564        }
565
566        public void remove() {
567            mListView.removeCallbacks(this);
568        }
569
570        @Override
571        public void run() {
572            final int count = mListView.getAdapter().getCount();
573            final String text;
574            if (count <= 0) {
575                text = mContext.getString(R.string.autofill_picker_no_suggestions);
576            } else {
577                text = mContext.getResources().getQuantityString(
578                        R.plurals.autofill_picker_some_suggestions, count, count);
579            }
580            mListView.announceForAccessibility(text);
581        }
582    }
583}
584