blob: 72a38eb14e21b3ec048681798bf5f0f7181a039b [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.server.autofill.ui;
import static com.android.server.autofill.Helper.sDebug;
import static com.android.server.autofill.Helper.sVerbose;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.Dialog;
import android.content.ComponentName;
import android.content.Context;
import android.content.IntentSender;
import android.graphics.drawable.Drawable;
import android.service.autofill.Dataset;
import android.service.autofill.FillResponse;
import android.text.TextUtils;
import android.util.PluralsMessageFormatter;
import android.util.Slog;
import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityManager;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillValue;
import android.widget.AdapterView;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.RemoteViews;
import android.widget.TextView;
import com.android.internal.R;
import com.android.server.autofill.AutofillManagerService;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* A dialog to show Autofill suggestions.
*
* This fill dialog UI shows as a bottom sheet style dialog. This dialog UI
* provides a larger area to display the suggestions, it provides a more
* conspicuous and efficient interface to the user. So it is easy for users
* to pay attention to the datasets and selecting one of them.
*/
final class DialogFillUi {
private static final String TAG = "DialogFillUi";
private static final int THEME_ID_LIGHT =
R.style.Theme_DeviceDefault_Light_Autofill_Save;
private static final int THEME_ID_DARK =
R.style.Theme_DeviceDefault_Autofill_Save;
interface UiCallback {
void onResponsePicked(@NonNull FillResponse response);
void onDatasetPicked(@NonNull Dataset dataset);
void onDismissed();
void onCanceled();
void startIntentSender(IntentSender intentSender);
}
private final @NonNull Dialog mDialog;
private final @NonNull OverlayControl mOverlayControl;
private final String mServicePackageName;
private final ComponentName mComponentName;
private final int mThemeId;
private final @NonNull Context mContext;
private final @NonNull UiCallback mCallback;
private final @NonNull ListView mListView;
private final @Nullable ItemsAdapter mAdapter;
private final int mVisibleDatasetsMaxCount;
private @Nullable String mFilterText;
private @Nullable AnnounceFilterResult mAnnounceFilterResult;
private boolean mDestroyed;
DialogFillUi(@NonNull 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) {
if (sVerbose) Slog.v(TAG, "nightMode: " + nightMode);
mThemeId = nightMode ? THEME_ID_DARK : THEME_ID_LIGHT;
mCallback = callback;
mOverlayControl = overlayControl;
mServicePackageName = servicePackageName;
mComponentName = componentName;
mContext = new ContextThemeWrapper(context, mThemeId);
final LayoutInflater inflater = LayoutInflater.from(mContext);
final View decor = inflater.inflate(R.layout.autofill_fill_dialog, null);
if (response.getShowFillDialogIcon()) {
setServiceIcon(decor, serviceIcon);
}
setHeader(decor, response);
mVisibleDatasetsMaxCount = getVisibleDatasetsMaxCount();
if (response.getAuthentication() != null) {
mListView = null;
mAdapter = null;
try {
initialAuthenticationLayout(decor, response);
} catch (RuntimeException e) {
callback.onCanceled();
Slog.e(TAG, "Error inflating remote views", e);
mDialog = null;
return;
}
} else {
final List<ViewItem> items = createDatasetItems(response, focusedViewId);
mAdapter = new ItemsAdapter(items);
mListView = decor.findViewById(R.id.autofill_dialog_list);
initialDatasetLayout(decor, filterText);
}
setDismissButton(decor);
mDialog = new Dialog(mContext, mThemeId);
mDialog.setContentView(decor);
setDialogParamsAsBottomSheet();
mDialog.setOnCancelListener((d) -> mCallback.onCanceled());
show();
}
private int getVisibleDatasetsMaxCount() {
if (AutofillManagerService.getVisibleDatasetsMaxCount() > 0) {
final int maxCount = AutofillManagerService.getVisibleDatasetsMaxCount();
if (sVerbose) {
Slog.v(TAG, "overriding maximum visible datasets to " + maxCount);
}
return maxCount;
} else {
return mContext.getResources()
.getInteger(com.android.internal.R.integer.autofill_max_visible_datasets);
}
}
private void setDialogParamsAsBottomSheet() {
final Window window = mDialog.getWindow();
window.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
| WindowManager.LayoutParams.FLAG_DIM_BEHIND);
window.setDimAmount(0.6f);
window.addPrivateFlags(WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS);
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN);
window.setGravity(Gravity.BOTTOM | Gravity.CENTER);
window.setCloseOnTouchOutside(true);
final WindowManager.LayoutParams params = window.getAttributes();
params.width = WindowManager.LayoutParams.MATCH_PARENT;
params.accessibilityTitle =
mContext.getString(R.string.autofill_picker_accessibility_title);
params.windowAnimations = R.style.AutofillSaveAnimation;
}
private void setServiceIcon(View decor, Drawable serviceIcon) {
if (serviceIcon == null) {
return;
}
final ImageView iconView = decor.findViewById(R.id.autofill_service_icon);
final int actualWidth = serviceIcon.getMinimumWidth();
final int actualHeight = serviceIcon.getMinimumHeight();
if (sDebug) {
Slog.d(TAG, "Adding service icon "
+ "(" + actualWidth + "x" + actualHeight + ")");
}
iconView.setImageDrawable(serviceIcon);
iconView.setVisibility(View.VISIBLE);
}
private void setHeader(View decor, FillResponse response) {
final RemoteViews presentation = response.getDialogHeader();
if (presentation == null) {
return;
}
final ViewGroup container = decor.findViewById(R.id.autofill_dialog_header);
final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> {
if (pendingIntent != null) {
mCallback.startIntentSender(pendingIntent.getIntentSender());
}
return true;
};
final View content = presentation.applyWithTheme(
mContext, (ViewGroup) decor, interceptionHandler, mThemeId);
container.addView(content);
container.setVisibility(View.VISIBLE);
}
private void setDismissButton(View decor) {
final TextView noButton = decor.findViewById(R.id.autofill_dialog_no);
// set "No thinks" by default
noButton.setText(R.string.autofill_save_no);
noButton.setOnClickListener((v) -> mCallback.onDismissed());
}
private void setContinueButton(View decor, View.OnClickListener listener) {
final TextView yesButton = decor.findViewById(R.id.autofill_dialog_yes);
// set "Continue" by default
yesButton.setText(R.string.autofill_continue_yes);
yesButton.setOnClickListener(listener);
yesButton.setVisibility(View.VISIBLE);
}
private void initialAuthenticationLayout(View decor, FillResponse response) {
RemoteViews presentation = response.getDialogPresentation();
if (presentation == null) {
presentation = response.getPresentation();
}
if (presentation == null) {
throw new RuntimeException("No presentation for fill dialog authentication");
}
// insert authentication item under autofill_dialog_container
final ViewGroup container = decor.findViewById(R.id.autofill_dialog_container);
final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> {
if (pendingIntent != null) {
mCallback.startIntentSender(pendingIntent.getIntentSender());
}
return true;
};
final View content = presentation.applyWithTheme(
mContext, (ViewGroup) decor, interceptionHandler, mThemeId);
container.addView(content);
container.setVisibility(View.VISIBLE);
container.setFocusable(true);
container.setOnClickListener(v -> mCallback.onResponsePicked(response));
// just single item, set up continue button
setContinueButton(decor, v -> mCallback.onResponsePicked(response));
}
private ArrayList<ViewItem> createDatasetItems(FillResponse response,
AutofillId focusedViewId) {
final int datasetCount = response.getDatasets().size();
if (sVerbose) {
Slog.v(TAG, "Number datasets: " + datasetCount + " max visible: "
+ mVisibleDatasetsMaxCount);
}
final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> {
if (pendingIntent != null) {
mCallback.startIntentSender(pendingIntent.getIntentSender());
}
return true;
};
final ArrayList<ViewItem> items = new ArrayList<>(datasetCount);
for (int i = 0; i < datasetCount; i++) {
final Dataset dataset = response.getDatasets().get(i);
final int index = dataset.getFieldIds().indexOf(focusedViewId);
if (index >= 0) {
RemoteViews presentation = dataset.getFieldDialogPresentation(index);
if (presentation == null) {
if (sDebug) {
Slog.w(TAG, "not displaying UI on field " + focusedViewId + " because "
+ "service didn't provide a presentation for it on " + dataset);
}
continue;
}
final View view;
try {
if (sVerbose) Slog.v(TAG, "setting remote view for " + focusedViewId);
view = presentation.applyWithTheme(
mContext, null, interceptionHandler, mThemeId);
} catch (RuntimeException e) {
Slog.e(TAG, "Error inflating remote views", e);
continue;
}
// TODO: Extract the shared filtering logic here and in FillUi to a common
// method.
final Dataset.DatasetFieldFilter filter = dataset.getFilter(index);
Pattern filterPattern = null;
String valueText = null;
boolean filterable = true;
if (filter == null) {
final AutofillValue value = dataset.getFieldValues().get(index);
if (value != null && value.isText()) {
valueText = value.getTextValue().toString().toLowerCase();
}
} else {
filterPattern = filter.pattern;
if (filterPattern == null) {
if (sVerbose) {
Slog.v(TAG, "Explicitly disabling filter at id " + focusedViewId
+ " for dataset #" + index);
}
filterable = false;
}
}
items.add(new ViewItem(dataset, filterPattern, filterable, valueText, view));
}
}
return items;
}
private void initialDatasetLayout(View decor, String filterText) {
final AdapterView.OnItemClickListener onItemClickListener =
(adapter, view, position, id) -> {
final ViewItem vi = mAdapter.getItem(position);
mCallback.onDatasetPicked(vi.dataset);
};
mListView.setAdapter(mAdapter);
mListView.setVisibility(View.VISIBLE);
mListView.setOnItemClickListener(onItemClickListener);
if (mAdapter.getCount() == 1) {
// just single item, set up continue button
setContinueButton(decor, (v) ->
onItemClickListener.onItemClick(null, null, 0, 0));
}
if (filterText == null) {
mFilterText = null;
} else {
mFilterText = filterText.toLowerCase();
}
final int oldCount = mAdapter.getCount();
mAdapter.getFilter().filter(mFilterText, (count) -> {
if (mDestroyed) {
return;
}
if (count <= 0) {
if (sDebug) {
final int size = mFilterText == null ? 0 : mFilterText.length();
Slog.d(TAG, "No dataset matches filter with " + size + " chars");
}
mCallback.onCanceled();
} else {
if (mAdapter.getCount() > mVisibleDatasetsMaxCount) {
mListView.setVerticalScrollBarEnabled(true);
mListView.onVisibilityAggregated(true);
} else {
mListView.setVerticalScrollBarEnabled(false);
}
if (mAdapter.getCount() != oldCount) {
mListView.requestLayout();
}
}
});
}
private void show() {
Slog.i(TAG, "Showing fill dialog");
mDialog.show();
mOverlayControl.hideOverlays();
}
boolean isShowing() {
return mDialog.isShowing();
}
void hide() {
if (sVerbose) Slog.v(TAG, "Hiding fill dialog.");
try {
mDialog.hide();
} finally {
mOverlayControl.showOverlays();
}
}
void destroy() {
try {
if (sDebug) Slog.d(TAG, "destroy()");
throwIfDestroyed();
mDialog.dismiss();
mDestroyed = true;
} finally {
mOverlayControl.showOverlays();
}
}
private void throwIfDestroyed() {
if (mDestroyed) {
throw new IllegalStateException("cannot interact with a destroyed instance");
}
}
@Override
public String toString() {
// TODO toString
return "NO TITLE";
}
void dump(PrintWriter pw, String prefix) {
pw.print(prefix); pw.print("service: "); pw.println(mServicePackageName);
pw.print(prefix); pw.print("app: "); pw.println(mComponentName.toShortString());
pw.print(prefix); pw.print("theme id: "); pw.print(mThemeId);
switch (mThemeId) {
case THEME_ID_DARK:
pw.println(" (dark)");
break;
case THEME_ID_LIGHT:
pw.println(" (light)");
break;
default:
pw.println("(UNKNOWN_MODE)");
break;
}
final View view = mDialog.getWindow().getDecorView();
final int[] loc = view.getLocationOnScreen();
pw.print(prefix); pw.print("coordinates: ");
pw.print('('); pw.print(loc[0]); pw.print(','); pw.print(loc[1]); pw.print(')');
pw.print('(');
pw.print(loc[0] + view.getWidth()); pw.print(',');
pw.print(loc[1] + view.getHeight()); pw.println(')');
pw.print(prefix); pw.print("destroyed: "); pw.println(mDestroyed);
}
private void announceSearchResultIfNeeded() {
if (AccessibilityManager.getInstance(mContext).isEnabled()) {
if (mAnnounceFilterResult == null) {
mAnnounceFilterResult = new AnnounceFilterResult();
}
mAnnounceFilterResult.post();
}
}
// TODO: Below code copied from FullUi, Extract the shared filtering logic here
// and in FillUi to a common method.
private final class AnnounceFilterResult implements Runnable {
private static final int SEARCH_RESULT_ANNOUNCEMENT_DELAY = 1000; // 1 sec
public void post() {
remove();
mListView.postDelayed(this, SEARCH_RESULT_ANNOUNCEMENT_DELAY);
}
public void remove() {
mListView.removeCallbacks(this);
}
@Override
public void run() {
final int count = mListView.getAdapter().getCount();
final String text;
if (count <= 0) {
text = mContext.getString(R.string.autofill_picker_no_suggestions);
} else {
Map<String, Object> arguments = new HashMap<>();
arguments.put("count", count);
text = PluralsMessageFormatter.format(mContext.getResources(),
arguments,
R.string.autofill_picker_some_suggestions);
}
mListView.announceForAccessibility(text);
}
}
private final class ItemsAdapter extends BaseAdapter implements Filterable {
private @NonNull final List<ViewItem> mAllItems;
private @NonNull final List<ViewItem> mFilteredItems = new ArrayList<>();
ItemsAdapter(@NonNull List<ViewItem> items) {
mAllItems = Collections.unmodifiableList(new ArrayList<>(items));
mFilteredItems.addAll(items);
}
@Override
public Filter getFilter() {
return new Filter() {
@Override
protected FilterResults performFiltering(CharSequence filterText) {
// No locking needed as mAllItems is final an immutable
final List<ViewItem> filtered = mAllItems.stream()
.filter((item) -> item.matches(filterText))
.collect(Collectors.toList());
final FilterResults results = new FilterResults();
results.values = filtered;
results.count = filtered.size();
return results;
}
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
final boolean resultCountChanged;
final int oldItemCount = mFilteredItems.size();
mFilteredItems.clear();
if (results.count > 0) {
@SuppressWarnings("unchecked") final List<ViewItem> items =
(List<ViewItem>) results.values;
mFilteredItems.addAll(items);
}
resultCountChanged = (oldItemCount != mFilteredItems.size());
if (resultCountChanged) {
announceSearchResultIfNeeded();
}
notifyDataSetChanged();
}
};
}
@Override
public int getCount() {
return mFilteredItems.size();
}
@Override
public ViewItem getItem(int position) {
return mFilteredItems.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
return getItem(position).view;
}
@Override
public String toString() {
return "ItemsAdapter: [all=" + mAllItems + ", filtered=" + mFilteredItems + "]";
}
}
/**
* An item for the list view - either a (clickable) dataset or a (read-only) header / footer.
*/
private static class ViewItem {
public final @Nullable String value;
public final @Nullable Dataset dataset;
public final @NonNull View view;
public final @Nullable Pattern filter;
public final boolean filterable;
/**
* Default constructor.
*
* @param dataset dataset associated with the item
* @param filter optional filter set by the service to determine how the item should be
* filtered
* @param filterable optional flag set by the service to indicate this item should not be
* filtered (typically used when the dataset has value but it's sensitive, like a password)
* @param value dataset value
* @param view dataset presentation.
*/
ViewItem(@NonNull Dataset dataset, @Nullable Pattern filter, boolean filterable,
@Nullable String value, @NonNull View view) {
this.dataset = dataset;
this.value = value;
this.view = view;
this.filter = filter;
this.filterable = filterable;
}
/**
* Returns whether this item matches the value input by the user so it can be included
* in the filtered datasets.
*/
public boolean matches(CharSequence filterText) {
if (TextUtils.isEmpty(filterText)) {
// Always show item when the user input is empty
return true;
}
if (!filterable) {
// Service explicitly disabled filtering using a null Pattern.
return false;
}
final String constraintLowerCase = filterText.toString().toLowerCase();
if (filter != null) {
// Uses pattern provided by service
return filter.matcher(constraintLowerCase).matches();
} else {
// Compares it with dataset value with dataset
return (value == null)
? (dataset.getAuthentication() == null)
: value.toLowerCase().startsWith(constraintLowerCase);
}
}
@Override
public String toString() {
final StringBuilder builder = new StringBuilder("ViewItem:[view=")
.append(view.getAutofillId());
final String datasetId = dataset == null ? null : dataset.getId();
if (datasetId != null) {
builder.append(", dataset=").append(datasetId);
}
if (value != null) {
// Cannot print value because it could contain PII
builder.append(", value=").append(value.length()).append("_chars");
}
if (filterable) {
builder.append(", filterable");
}
if (filter != null) {
// Filter should not have PII, but it could be a huge regexp
builder.append(", filter=").append(filter.pattern().length()).append("_chars");
}
return builder.append(']').toString();
}
}
}