FillUi.java revision b078212a1eb33d68f821b2aae0b18004e0c7c17e
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) {
208                    final int size = mFilterText == null ? 0 : mFilterText.length();
209                    Slog.d(TAG, "No dataset matches filter with " + size + " chars");
210                }
211                mCallback.requestHideFillUi();
212            } else {
213                if (updateContentSize()) {
214                    mCallback.requestShowFillUi(mContentWidth, mContentHeight, mWindowPresenter);
215                }
216                if (mAdapter.getCount() > VISIBLE_OPTIONS_MAX_COUNT) {
217                    mListView.setVerticalScrollBarEnabled(true);
218                    mListView.onVisibilityAggregated(true);
219                } else {
220                    mListView.setVerticalScrollBarEnabled(false);
221                }
222                if (mAdapter.getCount() != oldCount) {
223                    mListView.requestLayout();
224                }
225            }
226        });
227    }
228
229    public void setFilterText(@Nullable String filterText) {
230        throwIfDestroyed();
231        if (mAdapter == null) {
232            return;
233        }
234
235        if (filterText == null) {
236            filterText = null;
237        } else {
238            filterText = filterText.toLowerCase();
239        }
240
241        if (Objects.equal(mFilterText, filterText)) {
242            return;
243        }
244        mFilterText = filterText;
245
246        applyNewFilterText();
247    }
248
249    public void destroy() {
250        throwIfDestroyed();
251        mCallback.onDestroy();
252        mCallback.requestHideFillUi();
253        mDestroyed = true;
254    }
255
256    private boolean updateContentSize() {
257        if (mAdapter == null) {
258            return false;
259        }
260        boolean changed = false;
261        if (mAdapter.getCount() <= 0) {
262            if (mContentWidth != 0) {
263                mContentWidth = 0;
264                changed = true;
265            }
266            if (mContentHeight != 0) {
267                mContentHeight = 0;
268                changed = true;
269            }
270            return changed;
271        }
272
273        Point maxSize = mTempPoint;
274        resolveMaxWindowSize(mContext, maxSize);
275
276        mContentWidth = 0;
277        mContentHeight = 0;
278
279        final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(maxSize.x,
280                MeasureSpec.AT_MOST);
281        final int heightMeasureSpec = MeasureSpec.makeMeasureSpec(maxSize.y,
282                MeasureSpec.AT_MOST);
283        final int itemCount = mAdapter.getCount();
284        for (int i = 0; i < itemCount; i++) {
285            View view = mAdapter.getItem(i).getView();
286            view.measure(widthMeasureSpec, heightMeasureSpec);
287            final int clampedMeasuredWidth = Math.min(view.getMeasuredWidth(), maxSize.x);
288            final int newContentWidth = Math.max(mContentWidth, clampedMeasuredWidth);
289            if (newContentWidth != mContentWidth) {
290                mContentWidth = newContentWidth;
291                changed = true;
292            }
293            // Update the width to fit only the first items up to max count
294            if (i < VISIBLE_OPTIONS_MAX_COUNT) {
295                final int clampedMeasuredHeight = Math.min(view.getMeasuredHeight(), maxSize.y);
296                final int newContentHeight = mContentHeight + clampedMeasuredHeight;
297                if (newContentHeight != mContentHeight) {
298                    mContentHeight = newContentHeight;
299                    changed = true;
300                }
301            }
302        }
303        return changed;
304    }
305
306    private void throwIfDestroyed() {
307        if (mDestroyed) {
308            throw new IllegalStateException("cannot interact with a destroyed instance");
309        }
310    }
311
312    private static void resolveMaxWindowSize(Context context, Point outPoint) {
313        context.getDisplay().getSize(outPoint);
314        TypedValue typedValue = sTempTypedValue;
315        context.getTheme().resolveAttribute(R.attr.autofillDatasetPickerMaxWidth,
316                typedValue, true);
317        outPoint.x = (int) typedValue.getFraction(outPoint.x, outPoint.x);
318        context.getTheme().resolveAttribute(R.attr.autofillDatasetPickerMaxHeight,
319                typedValue, true);
320        outPoint.y = (int) typedValue.getFraction(outPoint.y, outPoint.y);
321    }
322
323    private static class ViewItem {
324        private final String mValue;
325        private final Dataset mDataset;
326        private final View mView;
327
328        ViewItem(Dataset dataset, String value, View view) {
329            mDataset = dataset;
330            mValue = value;
331            mView = view;
332        }
333
334        public View getView() {
335            return mView;
336        }
337
338        public Dataset getDataset() {
339            return mDataset;
340        }
341
342        public String getValue() {
343            return mValue;
344        }
345
346        @Override
347        public String toString() {
348            // Used for filtering in the adapter
349            return mValue;
350        }
351    }
352
353    private final class AutofillWindowPresenter extends IAutofillWindowPresenter.Stub {
354        @Override
355        public void show(WindowManager.LayoutParams p, Rect transitionEpicenter,
356                boolean fitsSystemWindows, int layoutDirection) {
357            if (sVerbose) {
358                Slog.v(TAG, "AutofillWindowPresenter.show(): fit=" + fitsSystemWindows
359                        + ", epicenter="+ transitionEpicenter + ", dir=" + layoutDirection
360                        + ", params=" + p);
361            }
362            UiThread.getHandler().post(() -> mWindow.show(p));
363        }
364
365        @Override
366        public void hide(Rect transitionEpicenter) {
367            UiThread.getHandler().post(mWindow::hide);
368        }
369    }
370
371    final class AnchoredWindow implements View.OnTouchListener {
372        private final @NonNull OverlayControl mOverlayControl;
373        private final WindowManager mWm;
374        private final View mContentView;
375        private boolean mShowing;
376
377        /**
378         * Constructor.
379         *
380         * @param contentView content of the window
381         */
382        AnchoredWindow(View contentView, @NonNull OverlayControl overlayControl) {
383            mWm = contentView.getContext().getSystemService(WindowManager.class);
384            mContentView = contentView;
385            mOverlayControl = overlayControl;
386        }
387
388        /**
389         * Shows the window.
390         */
391        public void show(WindowManager.LayoutParams params) {
392            if (sVerbose) Slog.v(TAG, "show(): showing=" + mShowing + ", params="+  params);
393            try {
394                if (!mShowing) {
395                    params.accessibilityTitle = mContentView.getContext()
396                            .getString(R.string.autofill_picker_accessibility_title);
397                    mWm.addView(mContentView, params);
398                    mContentView.setOnTouchListener(this);
399                    mOverlayControl.hideOverlays();
400                    mShowing = true;
401                } else {
402                    mWm.updateViewLayout(mContentView, params);
403                }
404            } catch (WindowManager.BadTokenException e) {
405                if (sDebug) Slog.d(TAG, "Filed with with token " + params.token + " gone.");
406                mCallback.onDestroy();
407            } catch (IllegalStateException e) {
408                // WM throws an ISE if mContentView was added twice; this should never happen -
409                // since show() and hide() are always called in the UIThread - but when it does,
410                // it should not crash the system.
411                Slog.e(TAG, "Exception showing window " + params, e);
412                mCallback.onDestroy();
413            }
414        }
415
416        /**
417         * Hides the window.
418         */
419        void hide() {
420            try {
421                if (mShowing) {
422                    mContentView.setOnTouchListener(null);
423                    mWm.removeView(mContentView);
424                    mShowing = false;
425                }
426            } catch (IllegalStateException e) {
427                // WM might thrown an ISE when removing the mContentView; this should never
428                // happen - since show() and hide() are always called in the UIThread - but if it
429                // does, it should not crash the system.
430                Slog.e(TAG, "Exception hiding window ", e);
431                mCallback.onDestroy();
432            } finally {
433                mOverlayControl.showOverlays();
434            }
435        }
436
437        @Override
438        public boolean onTouch(View view, MotionEvent event) {
439            // When the window is touched outside, hide the window.
440            if (view == mContentView && event.getAction() == MotionEvent.ACTION_OUTSIDE) {
441                mCallback.onCanceled();
442                return true;
443            }
444            return false;
445        }
446
447    }
448
449    public void dump(PrintWriter pw, String prefix) {
450        pw.print(prefix); pw.print("mCallback: "); pw.println(mCallback != null);
451        pw.print(prefix); pw.print("mListView: "); pw.println(mListView);
452        pw.print(prefix); pw.print("mAdapter: "); pw.println(mAdapter != null);
453        pw.print(prefix); pw.print("mFilterText: "); pw.println(mFilterText);
454        pw.print(prefix); pw.print("mContentWidth: "); pw.println(mContentWidth);
455        pw.print(prefix); pw.print("mContentHeight: "); pw.println(mContentHeight);
456        pw.print(prefix); pw.print("mDestroyed: "); pw.println(mDestroyed);
457        pw.print(prefix); pw.print("mWindow: ");
458        if (mWindow == null) {
459            pw.println("N/A");
460        } else {
461            final String prefix2 = prefix + "  ";
462            pw.println();
463            pw.print(prefix2); pw.print("showing: "); pw.println(mWindow.mShowing);
464            pw.print(prefix2); pw.print("view: "); pw.println(mWindow.mContentView);
465            pw.print(prefix2); pw.print("screen coordinates: ");
466            if (mWindow.mContentView == null) {
467                pw.println("N/A");
468            } else {
469                final int[] coordinates = mWindow.mContentView.getLocationOnScreen();
470                pw.print(coordinates[0]); pw.print("x"); pw.println(coordinates[1]);
471            }
472        }
473    }
474
475    private void announceSearchResultIfNeeded() {
476        if (AccessibilityManager.getInstance(mContext).isEnabled()) {
477            if (mAnnounceFilterResult == null) {
478                mAnnounceFilterResult = new AnnounceFilterResult();
479            }
480            mAnnounceFilterResult.post();
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
489        ItemsAdapter(@NonNull List<ViewItem> items) {
490            mAllItems = Collections.unmodifiableList(new ArrayList<>(items));
491            mFilteredItems.addAll(items);
492        }
493
494        @Override
495        public Filter getFilter() {
496            return new Filter() {
497                @Override
498                protected FilterResults performFiltering(CharSequence constraint) {
499                    // No locking needed as mAllItems is final an immutable
500                    final FilterResults results = new FilterResults();
501                    if (TextUtils.isEmpty(constraint)) {
502                        results.values = mAllItems;
503                        results.count = mAllItems.size();
504                        return results;
505                    }
506                    final List<ViewItem> filteredItems = new ArrayList<>();
507                    final String constraintLowerCase = constraint.toString().toLowerCase();
508                    final int itemCount = mAllItems.size();
509                    for (int i = 0; i < itemCount; i++) {
510                        final ViewItem item = mAllItems.get(i);
511                        final String value = item.getValue();
512                        // No value, i.e. null, matches any filter
513                        if (value == null
514                                || value.toLowerCase().startsWith(constraintLowerCase)) {
515                            filteredItems.add(item);
516                        }
517                    }
518                    results.values = filteredItems;
519                    results.count = filteredItems.size();
520                    return results;
521                }
522
523                @Override
524                protected void publishResults(CharSequence constraint, FilterResults results) {
525                    final boolean resultCountChanged;
526                    final int oldItemCount = mFilteredItems.size();
527                    mFilteredItems.clear();
528                    @SuppressWarnings("unchecked")
529                    final List<ViewItem> items = (List<ViewItem>) results.values;
530                    mFilteredItems.addAll(items);
531                    resultCountChanged = (oldItemCount != mFilteredItems.size());
532                    if (resultCountChanged) {
533                        announceSearchResultIfNeeded();
534                    }
535                    notifyDataSetChanged();
536                }
537            };
538        }
539
540        @Override
541        public int getCount() {
542            return mFilteredItems.size();
543        }
544
545        @Override
546        public ViewItem getItem(int position) {
547            return mFilteredItems.get(position);
548        }
549
550        @Override
551        public long getItemId(int position) {
552            return position;
553        }
554
555        @Override
556        public View getView(int position, View convertView, ViewGroup parent) {
557            return getItem(position).getView();
558        }
559    }
560
561    private final class AnnounceFilterResult implements Runnable {
562        private static final int SEARCH_RESULT_ANNOUNCEMENT_DELAY = 1000; // 1 sec
563
564        public void post() {
565            remove();
566            mListView.postDelayed(this, SEARCH_RESULT_ANNOUNCEMENT_DELAY);
567        }
568
569        public void remove() {
570            mListView.removeCallbacks(this);
571        }
572
573        @Override
574        public void run() {
575            final int count = mListView.getAdapter().getCount();
576            final String text;
577            if (count <= 0) {
578                text = mContext.getString(R.string.autofill_picker_no_suggestions);
579            } else {
580                text = mContext.getResources().getQuantityString(
581                        R.plurals.autofill_picker_some_suggestions, count, count);
582            }
583            mListView.announceForAccessibility(text);
584        }
585    }
586}
587