blob: ab8e2b6a75f36d36534c7f5266954c3d8f75e625 [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.android_webview;
import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.support.annotation.VisibleForTesting;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStructure;
import android.view.autofill.AutofillValue;
import org.chromium.base.ThreadUtils;
import org.chromium.components.autofill.AutofillProvider;
import org.chromium.components.autofill.FormData;
import org.chromium.components.autofill.FormFieldData;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.display.DisplayAndroid;
/**
* This class uses Android autofill service to fill web form. All methods are
* supposed to be called in UI thread.
*
* This class doesn't have 1:1 mapping to native AutofillProviderAndroid and is
* same as how AwContents.java mapping to native AwContents, AwAutofillProvider
* is owned by AwContents.java and AutofillProviderAndroid is owned by native
* AwContents.
*/
@TargetApi(Build.VERSION_CODES.O)
public class AwAutofillProvider extends AutofillProvider {
private static class FocusField {
public final short fieldIndex;
public final Rect absBound;
public FocusField(short fieldIndex, Rect absBound) {
this.fieldIndex = fieldIndex;
this.absBound = absBound;
}
}
/**
* The class to wrap the request to framework.
*
* Though framework guarantees always giving us the autofill value of current
* session, we still want to verify this by using unique virtual id which is
* composed of sessionId and form field index, we don't use the request id
* which comes from renderer as session id because it is not unique.
*/
private static class AutofillRequest {
private static final int INIT_ID = 1; // ID can't be 0 in Android.
private static int sSessionId = INIT_ID;
public final int sessionId;
private FormData mFormData;
private FocusField mFocusField;
public AutofillRequest(FormData formData, FocusField focus) {
sessionId = getNextClientId();
mFormData = formData;
mFocusField = focus;
}
public void fillViewStructure(ViewStructure structure) {
structure.setWebDomain(mFormData.mHost);
structure.setHtmlInfo(structure.newHtmlInfoBuilder("form")
.addAttribute("name", mFormData.mName)
.build());
int index = structure.addChildCount(mFormData.mFields.size());
short fieldIndex = 0;
for (FormFieldData field : mFormData.mFields) {
ViewStructure child = structure.newChild(index++);
int virtualId = toVirtualId(sessionId, fieldIndex++);
child.setAutofillId(structure.getAutofillId(), virtualId);
if (field.mAutocompleteAttr != null && !field.mAutocompleteAttr.isEmpty()) {
child.setAutofillHints(field.mAutocompleteAttr.split(" +"));
}
child.setHint(field.mPlaceholder);
ViewStructure.HtmlInfo.Builder builder =
child.newHtmlInfoBuilder("input")
.addAttribute("name", field.mName)
.addAttribute("type", field.mType)
.addAttribute("label", field.mLabel)
.addAttribute("ua-autofill-hints", field.mHeuristicType)
.addAttribute("id", field.mId);
switch (field.getControlType()) {
case FormFieldData.ControlType.LIST:
child.setAutofillType(View.AUTOFILL_TYPE_LIST);
child.setAutofillOptions(field.mOptionContents);
int i = findIndex(field.mOptionValues, field.getValue());
if (i != -1) {
child.setAutofillValue(AutofillValue.forList(i));
}
break;
case FormFieldData.ControlType.TOGGLE:
child.setAutofillType(View.AUTOFILL_TYPE_TOGGLE);
child.setAutofillValue(AutofillValue.forToggle(field.isChecked()));
break;
case FormFieldData.ControlType.TEXT:
child.setAutofillType(View.AUTOFILL_TYPE_TEXT);
child.setAutofillValue(AutofillValue.forText(field.getValue()));
if (field.mMaxLength != 0) {
builder.addAttribute("maxlength", String.valueOf(field.mMaxLength));
}
break;
default:
break;
}
child.setHtmlInfo(builder.build());
}
}
public boolean autofill(final SparseArray<AutofillValue> values) {
for (int i = 0; i < values.size(); ++i) {
int id = values.keyAt(i);
if (toSessionId(id) != sessionId) return false;
AutofillValue value = values.get(id);
if (value == null) continue;
short index = toIndex(id);
if (index < 0 || index >= mFormData.mFields.size()) return false;
FormFieldData field = mFormData.mFields.get(index);
if (field == null) return false;
switch (field.getControlType()) {
case FormFieldData.ControlType.LIST:
int j = value.getListValue();
if (j < 0 && j >= field.mOptionValues.length) continue;
field.setAutofillValue(field.mOptionValues[j]);
break;
case FormFieldData.ControlType.TOGGLE:
field.setChecked(value.getToggleValue());
break;
case FormFieldData.ControlType.TEXT:
field.setAutofillValue((String) value.getTextValue());
break;
default:
break;
}
}
return true;
}
public void setFocusField(FocusField focusField) {
mFocusField = focusField;
}
public FocusField getFocusField() {
return mFocusField;
}
public int getFieldCount() {
return mFormData.mFields.size();
}
public AutofillValue getFieldNewValue(int index) {
FormFieldData field = mFormData.mFields.get(index);
if (field == null) return null;
switch (field.getControlType()) {
case FormFieldData.ControlType.LIST:
int i = findIndex(field.mOptionValues, field.getValue());
if (i == -1) return null;
return AutofillValue.forList(i);
case FormFieldData.ControlType.TOGGLE:
return AutofillValue.forToggle(field.isChecked());
case FormFieldData.ControlType.TEXT:
return AutofillValue.forText(field.getValue());
default:
return null;
}
}
public int getVirtualId(short index) {
return toVirtualId(sessionId, index);
}
public FormFieldData getField(short index) {
return mFormData.mFields.get(index);
}
private static int findIndex(String[] values, String value) {
if (values != null && value != null) {
for (int i = 0; i < values.length; i++) {
if (value.equals(values[i])) return i;
}
}
return -1;
}
private static int getNextClientId() {
ThreadUtils.assertOnUiThread();
if (sSessionId == 0xffff) sSessionId = INIT_ID;
return sSessionId++;
}
private static int toSessionId(int virtualId) {
return (virtualId & 0xffff0000) >> 16;
}
private static short toIndex(int virtualId) {
return (short) (virtualId & 0xffff);
}
private static int toVirtualId(int clientId, short index) {
return (clientId << 16) | index;
}
}
private AwAutofillManager mAutofillManager;
private ViewGroup mContainerView;
private WebContents mWebContents;
private AutofillRequest mRequest;
private long mNativeAutofillProvider;
private AwAutofillUMA mAutofillUMA;
private AwAutofillManager.InputUIObserver mInputUIObserver;
private long mAutofillTriggeredTimeMillis;
public AwAutofillProvider(Context context, ViewGroup containerView) {
this(containerView, new AwAutofillManager(context), context);
}
@VisibleForTesting
public AwAutofillProvider(ViewGroup containerView, AwAutofillManager manager, Context context) {
try (ScopedSysTraceEvent e = ScopedSysTraceEvent.scoped("AwAutofillProvider.constructor")) {
assert Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
mAutofillManager = manager;
mContainerView = containerView;
mAutofillUMA = new AwAutofillUMA(context);
mInputUIObserver = new AwAutofillManager.InputUIObserver() {
@Override
public void onInputUIShown() {
// Not need to report suggestion window displayed if there is no live autofill
// session.
if (mRequest == null) return;
mAutofillUMA.onSuggestionDisplayed(
System.currentTimeMillis() - mAutofillTriggeredTimeMillis);
}
};
mAutofillManager.addInputUIObserver(mInputUIObserver);
}
}
@Override
public void onContainerViewChanged(ViewGroup containerView) {
mContainerView = containerView;
}
@Override
public void onProvideAutoFillVirtualStructure(ViewStructure structure, int flags) {
// This method could be called for the session started by the native
// control outside of WebView, e.g. the URL bar, in this case, we simply
// return.
if (mRequest == null) return;
mRequest.fillViewStructure(structure);
if (AwAutofillManager.isLoggable()) {
AwAutofillManager.log(
"onProvideAutoFillVirtualStructure fields:" + structure.getChildCount());
}
mAutofillUMA.onVirtualStructureProvided();
}
@Override
public void autofill(final SparseArray<AutofillValue> values) {
if (mNativeAutofillProvider != 0 && mRequest != null && mRequest.autofill((values))) {
autofill(mNativeAutofillProvider, mRequest.mFormData);
if (AwAutofillManager.isLoggable()) {
AwAutofillManager.log("autofill values:" + values.size());
}
mAutofillUMA.onAutofill();
}
}
@Override
public boolean shouldQueryAutofillSuggestion() {
return mRequest != null && mRequest.getFocusField() != null
&& !mAutofillManager.isAutofillInputUIShowing();
}
@Override
public void queryAutofillSuggestion() {
if (shouldQueryAutofillSuggestion()) {
FocusField focusField = mRequest.getFocusField();
mAutofillManager.requestAutofill(mContainerView,
mRequest.getVirtualId(focusField.fieldIndex), focusField.absBound);
}
}
@Override
public void startAutofillSession(
FormData formData, int focus, float x, float y, float width, float height) {
// Check focusField inside short value?
// Autofill Manager might have session that wasn't started by WebView,
// we just always cancel existing session here.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
mAutofillManager.cancel();
}
mAutofillManager.notifyNewSessionStarted();
Rect absBound = transformToWindowBounds(new RectF(x, y, x + width, y + height));
mRequest = new AutofillRequest(formData, new FocusField((short) focus, absBound));
int virtualId = mRequest.getVirtualId((short) focus);
mAutofillManager.notifyVirtualViewEntered(mContainerView, virtualId, absBound);
mAutofillUMA.onSessionStarted(mAutofillManager.isDisabled());
mAutofillTriggeredTimeMillis = System.currentTimeMillis();
}
@Override
public void onFormFieldDidChange(int index, float x, float y, float width, float height) {
// Check index inside short value?
if (mRequest == null) return;
short sIndex = (short) index;
FocusField focusField = mRequest.getFocusField();
if (focusField == null || sIndex != focusField.fieldIndex) {
onFocusChangedImpl(true, index, x, y, width, height, true /*causedByValueChange*/);
} else {
// Currently there is no api to notify both value and position
// change, before the API is available, we still need to call
// notifyVirtualViewEntered() to tell current coordinates because
// the position could be changed.
int virtualId = mRequest.getVirtualId(sIndex);
Rect absBound = transformToWindowBounds(new RectF(x, y, x + width, y + height));
mAutofillManager.notifyVirtualViewExited(mContainerView, virtualId);
mAutofillManager.notifyVirtualViewEntered(mContainerView, virtualId, absBound);
// Update focus field position.
mRequest.setFocusField(new FocusField(focusField.fieldIndex, absBound));
}
notifyVirtualValueChanged(index);
mAutofillUMA.onUserChangeFieldValue(mRequest.getField(sIndex).hasPreviouslyAutofilled());
}
@Override
public void onTextFieldDidScroll(int index, float x, float y, float width, float height) {
// crbug.com/730764 - from P and above, Android framework listens to the onScrollChanged()
// and repositions the autofill UI automatically.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) return;
if (mRequest == null) return;
short sIndex = (short) index;
FocusField focusField = mRequest.getFocusField();
if (focusField == null || sIndex != focusField.fieldIndex) return;
int virtualId = mRequest.getVirtualId(sIndex);
Rect absBound = transformToWindowBounds(new RectF(x, y, x + width, y + height));
// Notify the new position to the Android framework. Note that we do not call
// notifyVirtualViewExited() here intentionally to avoid flickering.
mAutofillManager.notifyVirtualViewEntered(mContainerView, virtualId, absBound);
// Update focus field position.
mRequest.setFocusField(new FocusField(focusField.fieldIndex, absBound));
}
private void notifyVirtualValueChanged(int index) {
AutofillValue autofillValue = mRequest.getFieldNewValue(index);
if (autofillValue == null) return;
mAutofillManager.notifyVirtualValueChanged(
mContainerView, mRequest.getVirtualId((short) index), autofillValue);
}
@Override
public void onFormSubmitted(int submissionSource) {
// The changes could be missing, like those made by Javascript, we'd better to notify
// AutofillManager current values. also see crbug.com/353001 and crbug.com/732856.
notifyFormValues();
mAutofillManager.commit(submissionSource);
mRequest = null;
mAutofillUMA.onFormSubmitted(submissionSource);
}
@Override
public void onFocusChanged(
boolean focusOnForm, int focusField, float x, float y, float width, float height) {
onFocusChangedImpl(
focusOnForm, focusField, x, y, width, height, false /*causedByValueChange*/);
}
private void onFocusChangedImpl(boolean focusOnForm, int focusField, float x, float y,
float width, float height, boolean causedByValueChange) {
// Check focusField inside short value?
// FocusNoLongerOnForm is called after form submitted.
if (mRequest == null) return;
FocusField prev = mRequest.getFocusField();
if (focusOnForm) {
Rect absBound = transformToWindowBounds(new RectF(x, y, x + width, y + height));
if (prev != null && prev.fieldIndex == focusField && absBound.equals(prev.absBound)) {
return;
}
// Notify focus changed.
if (prev != null) {
mAutofillManager.notifyVirtualViewExited(
mContainerView, mRequest.getVirtualId(prev.fieldIndex));
}
mAutofillManager.notifyVirtualViewEntered(
mContainerView, mRequest.getVirtualId((short) focusField), absBound);
if (!causedByValueChange) {
// The focus field value might not sync with platform's
// AutofillManager, just notify it value changed.
notifyVirtualValueChanged(focusField);
mAutofillTriggeredTimeMillis = System.currentTimeMillis();
}
mRequest.setFocusField(new FocusField((short) focusField, absBound));
} else {
if (prev == null) return;
// Notify focus changed.
mAutofillManager.notifyVirtualViewExited(
mContainerView, mRequest.getVirtualId(prev.fieldIndex));
mRequest.setFocusField(null);
}
}
@Override
protected void reset() {
// We don't need to reset anything here, it should be safe to cancel
// current autofill session when new one starts in
// startAutofillSession().
}
@Override
protected void setNativeAutofillProvider(long nativeAutofillProvider) {
if (nativeAutofillProvider == mNativeAutofillProvider) return;
// Setting the mNativeAutofillProvider to 0 may occur as a
// result of WebView.destroy, or because a WebView has been
// gc'ed. In the former case we can go ahead and clean up the
// frameworks autofill manager, but in the latter case the
// binder connection has already been dropped in a framework
// finalizer, and so the methods we call will throw. It's not
// possible to know which case we're in, so just catch and
// ignore the exception.
try {
if (mNativeAutofillProvider != 0) mRequest = null;
mNativeAutofillProvider = nativeAutofillProvider;
if (nativeAutofillProvider == 0) mAutofillManager.destroy();
} catch (IllegalStateException e) {
}
}
@Override
public void setWebContents(WebContents webContents) {
if (webContents == mWebContents) return;
if (mWebContents != null) mRequest = null;
mWebContents = webContents;
}
@Override
protected void onDidFillAutofillFormData() {
notifyFormValues();
}
private void notifyFormValues() {
if (mRequest == null) return;
for (int i = 0; i < mRequest.getFieldCount(); ++i) notifyVirtualValueChanged(i);
}
private Rect transformToWindowBounds(RectF rect) {
// Convert bounds to device pixel.
WindowAndroid windowAndroid = mWebContents.getTopLevelNativeWindow();
DisplayAndroid displayAndroid = windowAndroid.getDisplay();
float dipScale = displayAndroid.getDipScale();
RectF bounds = new RectF(rect);
Matrix matrix = new Matrix();
matrix.setScale(dipScale, dipScale);
int[] location = new int[2];
mContainerView.getLocationOnScreen(location);
matrix.postTranslate(location[0], location[1]);
matrix.mapRect(bounds);
return new Rect(
(int) bounds.left, (int) bounds.top, (int) bounds.right, (int) bounds.bottom);
}
}