Add bottom sheet fill UI for Autofill
Create the fill dialog UI. Its layout is similar to FillUi but uses dialog styles similar to SaveUi. Bug: 210926084 Test: Manual Change-Id: I9e5dfcc1cd0afdc8cc26ede401418dbfa2467d23
This commit is contained in:
115
core/res/res/layout/autofill_fill_dialog.xml
Normal file
115
core/res/res/layout/autofill_fill_dialog.xml
Normal file
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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.
|
||||
-->
|
||||
|
||||
<!-- NOTE: outer layout is required to provide proper shadow. -->
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/autofill_dialog_picker"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/autofill_save_outer_top_margin"
|
||||
android:padding="@dimen/autofill_save_outer_top_padding"
|
||||
android:elevation="@dimen/autofill_elevation"
|
||||
android:background="?android:attr/colorBackground"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingStart="@dimen/autofill_save_inner_padding"
|
||||
android:paddingEnd="@dimen/autofill_save_inner_padding"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/autofill_service_icon"
|
||||
android:scaleType="fitStart"
|
||||
android:visibility="gone"
|
||||
android:layout_width="@dimen/autofill_dialog_icon_size"
|
||||
android:layout_height="@dimen/autofill_dialog_icon_size"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/autofill_dialog_header"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingStart="@dimen/autofill_save_inner_padding"
|
||||
android:paddingEnd="@dimen/autofill_save_inner_padding"
|
||||
android:visibility="gone"
|
||||
android:foreground="?attr/listChoiceBackgroundIndicator"
|
||||
style="@style/AutofillDatasetPicker" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/autofill_dialog_container"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingStart="@dimen/autofill_save_inner_padding"
|
||||
android:paddingEnd="@dimen/autofill_save_inner_padding"
|
||||
android:visibility="gone"
|
||||
android:foreground="?attr/listChoiceBackgroundIndicator"
|
||||
style="@style/AutofillDatasetPicker" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/autofill_dialog_list"
|
||||
android:layout_weight="1"
|
||||
android:layout_width="fill_parent"
|
||||
android:layout_height="0dp"
|
||||
android:drawSelectorOnTop="true"
|
||||
android:clickable="true"
|
||||
android:divider="@null"
|
||||
android:visibility="gone"
|
||||
android:paddingStart="@dimen/autofill_save_inner_padding"
|
||||
android:paddingEnd="@dimen/autofill_save_inner_padding"
|
||||
android:foreground="?attr/listChoiceBackgroundIndicator"
|
||||
style="@style/AutofillDatasetPicker" />
|
||||
|
||||
<com.android.internal.widget.ButtonBarLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:padding="@dimen/autofill_save_button_bar_padding"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/autofill_dialog_no"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="?android:attr/buttonBarButtonStyle"
|
||||
android:text="@string/dismiss_action">
|
||||
</Button>
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:visibility="invisible">
|
||||
</Space>
|
||||
|
||||
<Button
|
||||
android:id="@+id/autofill_dialog_yes"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/Widget.DeviceDefault.Button.Colored"
|
||||
android:text="@string/autofill_save_yes"
|
||||
android:visibility="gone" >
|
||||
</Button>
|
||||
|
||||
</com.android.internal.widget.ButtonBarLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -877,6 +877,9 @@
|
||||
<!-- Maximum number of datasets that are visible in the UX picker without scrolling -->
|
||||
<integer name="autofill_max_visible_datasets">3</integer>
|
||||
|
||||
<!-- Size of an icon in the Autolfill fill dialog -->
|
||||
<dimen name="autofill_dialog_icon_size">56dp</dimen>
|
||||
|
||||
<!-- Size of a slice shortcut view -->
|
||||
<dimen name="slice_shortcut_size">56dp</dimen>
|
||||
<!-- Size of action icons in a slice -->
|
||||
|
||||
@@ -3504,6 +3504,7 @@
|
||||
<java-symbol type="layout" name="autofill_dataset_picker"/>
|
||||
<java-symbol type="layout" name="autofill_dataset_picker_fullscreen"/>
|
||||
<java-symbol type="layout" name="autofill_dataset_picker_header_footer"/>
|
||||
<java-symbol type="layout" name="autofill_fill_dialog"/>
|
||||
<java-symbol type="id" name="autofill" />
|
||||
<java-symbol type="id" name="autofill_dataset_footer"/>
|
||||
<java-symbol type="id" name="autofill_dataset_header"/>
|
||||
@@ -3516,6 +3517,13 @@
|
||||
<java-symbol type="id" name="autofill_save_no" />
|
||||
<java-symbol type="id" name="autofill_save_title" />
|
||||
<java-symbol type="id" name="autofill_save_yes" />
|
||||
<java-symbol type="id" name="autofill_service_icon" />
|
||||
<java-symbol type="id" name="autofill_dialog_picker"/>
|
||||
<java-symbol type="id" name="autofill_dialog_header"/>
|
||||
<java-symbol type="id" name="autofill_dialog_container"/>
|
||||
<java-symbol type="id" name="autofill_dialog_list"/>
|
||||
<java-symbol type="id" name="autofill_dialog_no" />
|
||||
<java-symbol type="id" name="autofill_dialog_yes" />
|
||||
<java-symbol type="string" name="autofill_error_cannot_autofill" />
|
||||
<java-symbol type="string" name="autofill_picker_no_suggestions" />
|
||||
<java-symbol type="string" name="autofill_picker_some_suggestions" />
|
||||
|
||||
@@ -66,6 +66,7 @@ public final class AutoFillUI {
|
||||
|
||||
private @Nullable FillUi mFillUi;
|
||||
private @Nullable SaveUi mSaveUi;
|
||||
private @Nullable DialogFillUi mFillDialog;
|
||||
|
||||
private @Nullable AutoFillUiCallback mCallback;
|
||||
|
||||
@@ -387,7 +388,48 @@ public final class AutoFillUI {
|
||||
+ componentName.toShortString() + ": " + response);
|
||||
}
|
||||
|
||||
// TODO show fill dialog
|
||||
// TODO: enable LogMaker
|
||||
|
||||
mHandler.post(() -> {
|
||||
if (callback != mCallback) {
|
||||
return;
|
||||
}
|
||||
hideAllUiThread(callback);
|
||||
mFillDialog = new DialogFillUi(mContext, response, focusedId, filterText,
|
||||
serviceIcon, servicePackageName, componentName, mOverlayControl,
|
||||
mUiModeMgr.isNightMode(), new DialogFillUi.UiCallback() {
|
||||
@Override
|
||||
public void onResponsePicked(FillResponse response) {
|
||||
hideFillDialogUiThread(callback);
|
||||
if (mCallback != null) {
|
||||
mCallback.authenticate(response.getRequestId(),
|
||||
AutofillManager.AUTHENTICATION_ID_DATASET_ID_UNDEFINED,
|
||||
response.getAuthentication(), response.getClientState(),
|
||||
/* authenticateInline= */ false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDatasetPicked(Dataset dataset) {
|
||||
hideFillDialogUiThread(callback);
|
||||
if (mCallback != null) {
|
||||
final int datasetIndex = response.getDatasets().indexOf(dataset);
|
||||
mCallback.fill(response.getRequestId(), datasetIndex, dataset);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCanceled() {
|
||||
hideFillDialogUiThread(callback);
|
||||
callback.requestShowSoftInput(focusedId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startIntentSender(IntentSender intentSender) {
|
||||
mCallback.startIntentSenderAndFinishSession(intentSender);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,8 +465,7 @@ public final class AutoFillUI {
|
||||
}
|
||||
|
||||
public boolean isFillDialogShowing() {
|
||||
// TODO
|
||||
return false;
|
||||
return mFillDialog == null ? false : mFillDialog.isShowing();
|
||||
}
|
||||
|
||||
public void dump(PrintWriter pw) {
|
||||
@@ -444,6 +485,12 @@ public final class AutoFillUI {
|
||||
} else {
|
||||
pw.print(prefix); pw.println("showsSaveUi: false");
|
||||
}
|
||||
if (mFillDialog != null) {
|
||||
pw.print(prefix); pw.println("showsFillDialog: true");
|
||||
mFillDialog.dump(pw, prefix2);
|
||||
} else {
|
||||
pw.print(prefix); pw.println("showsFillDialog: false");
|
||||
}
|
||||
}
|
||||
|
||||
@android.annotation.UiThread
|
||||
@@ -470,7 +517,10 @@ public final class AutoFillUI {
|
||||
|
||||
@android.annotation.UiThread
|
||||
private void hideFillDialogUiThread(@Nullable AutoFillUiCallback callback) {
|
||||
// TODO
|
||||
if (mFillDialog != null && (callback == null || callback == mCallback)) {
|
||||
mFillDialog.destroy();
|
||||
mFillDialog = null;
|
||||
}
|
||||
}
|
||||
|
||||
@android.annotation.UiThread
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
/*
|
||||
* 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 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);
|
||||
|
||||
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();
|
||||
|
||||
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_NOT_TOUCH_MODAL
|
||||
| WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH);
|
||||
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);
|
||||
noButton.setOnClickListener((v) -> mCallback.onCanceled());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
Slog.w(TAG, "fallback to presentation");
|
||||
presentation = dataset.getFieldPresentation(index);
|
||||
}
|
||||
if (presentation == null) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user