chromium/components/android_autofill/browser/junit/src/org/chromium/components/autofill/AutofillProviderTest.java

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.autofill;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.content.Context;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewGroup;
import android.view.autofill.AutofillManager;
import android.view.autofill.AutofillValue;
import android.view.autofill.VirtualViewFillInfo;

import androidx.annotation.RequiresApi;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.robolectric.annotation.Config;

import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.Features;
import org.chromium.base.test.util.JniMocker;
import org.chromium.content.browser.RenderCoordinatesImpl;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.display.DisplayAndroid;

import java.util.Arrays;
import java.util.Collections;

/** The unit tests for AutofillProvider. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
@Features.EnableFeatures({AndroidAutofillFeatures.ANDROID_AUTOFILL_BOTTOM_SHEET_WORKAROUND_NAME})
public class AutofillProviderTest {
    private static final float EXPECTED_DIP_SCALE = 2;
    private static final int SCROLL_X = 15;
    private static final int SCROLL_Y = 155;
    private static final int LOCATION_X = 25;
    private static final int LOCATION_Y = 255;

    private Context mContext;
    private WindowAndroid mWindowAndroid;
    private WebContents mWebContents;
    private ViewGroup mContainerView;
    private AutofillProvider mAutofillProvider;
    private DisplayAndroid mDisplayAndroid;
    private long mMockedNativeAndroidAutofillProvider = 1;

    // Virtual Id of the field with focus.
    private int mFocusVirtualId;

    // Virtual Id of the field to show the bottom sheet for.
    private int mDialogVirtualId;
    private SparseArray<VirtualViewFillInfo> mPrefillRequestInfos;

    @Rule public JniMocker mJniMocker = new JniMocker();
    @Mock private AutofillProvider.Natives mNativeMock;
    @Mock private RenderCoordinatesImpl mRenderCoordinates;
    @Mock private AutofillManager mAutofillManager;

    /** AutofillManagerWrapper which keeps track of the virtual id of the field with focus. */
    private class TestAutofillManagerWrapper extends AutofillManagerWrapper {

        public TestAutofillManagerWrapper(Context context) {
            super(context);
        }

        @Override
        public void notifyVirtualViewsReady(
                View parent, SparseArray<VirtualViewFillInfo> viewFillInfos) {
            mPrefillRequestInfos = viewFillInfos;
            super.notifyVirtualViewsReady(parent, viewFillInfos);
        }

        @Override
        public void notifyVirtualViewEntered(View parent, int childId, Rect absBounds) {
            mFocusVirtualId = childId;
            super.notifyVirtualViewEntered(parent, childId, absBounds);
        }

        @Override
        public boolean showAutofillDialog(View parent, int childId) {
            mDialogVirtualId = childId;
            return super.showAutofillDialog(parent, childId);
        }
    }

    @Before
    public void setUp() {
        MockitoAnnotations.openMocks(this);
        mContext = Mockito.mock(Context.class);
        when(mContext.getSystemService(AutofillManager.class)).thenReturn(mAutofillManager);
        when(mAutofillManager.isEnabled()).thenReturn(true);
        mWindowAndroid = Mockito.mock(WindowAndroid.class);
        mDisplayAndroid = Mockito.mock(DisplayAndroid.class);
        mWebContents = Mockito.mock(WebContents.class);
        mContainerView = Mockito.mock(ViewGroup.class);

        AutofillProvider.setAutofillManagerWrapperFactoryForTesting(
                (context) -> {
                    return new TestAutofillManagerWrapper(context);
                });

        mAutofillProvider =
                new AutofillProvider(
                        mContext, mContainerView, mWebContents, "AutofillProviderTest") {
                    @Override
                    protected void initializeNativeAutofillProvider(WebContents webContents) {
                        setNativeAutofillProvider(mMockedNativeAndroidAutofillProvider);
                    }
                };

        when(mWebContents.getTopLevelNativeWindow()).thenReturn(mWindowAndroid);
        when(mWindowAndroid.getDisplay()).thenReturn(mDisplayAndroid);
        when(mDisplayAndroid.getDipScale()).thenReturn(EXPECTED_DIP_SCALE);
        when(mContainerView.getScrollX()).thenReturn(SCROLL_X);
        when(mContainerView.getScrollY()).thenReturn(SCROLL_Y);
        doAnswer(
                        new Answer<Void>() {
                            @Override
                            public Void answer(InvocationOnMock invocation) {
                                Object[] args = invocation.getArguments();
                                int[] location = (int[]) args[0];
                                location[0] = LOCATION_X;
                                location[1] = LOCATION_Y;
                                return null;
                            }
                        })
                .when(mContainerView)
                .getLocationOnScreen(any());

        RenderCoordinatesImpl.setInstanceForTesting(mRenderCoordinates);
        when(mRenderCoordinates.getContentOffsetYPixInt()).thenReturn(0);

        mJniMocker.mock(AutofillProviderJni.TEST_HOOKS, mNativeMock);
    }

    @Test
    public void testTransformFormFieldToContainViewCoordinates() {
        FormFieldDataBuilder field1Builder = new FormFieldDataBuilder();
        field1Builder.mBounds =
                new RectF(/* left= */ 10, /* top= */ 20, /* right= */ 300, /* bottom= */ 60);
        FormFieldDataBuilder field2Builder = new FormFieldDataBuilder();
        field2Builder.mBounds =
                new RectF(/* left= */ 20, /* top= */ 100, /* right= */ 400, /* bottom= */ 200);

        FormData formData =
                new FormData(
                        /* sessionId= */ 123,
                        /* name= */ null,
                        /* host= */ null,
                        Arrays.asList(field1Builder.build(), field2Builder.build()));
        mAutofillProvider.transformFormFieldToContainViewCoordinates(formData);
        RectF result = formData.mFields.get(0).getBoundsInContainerViewCoordinates();
        assertEquals(10 * EXPECTED_DIP_SCALE + SCROLL_X, result.left, 0);
        assertEquals(20 * EXPECTED_DIP_SCALE + SCROLL_Y, result.top, 0);
        assertEquals(300 * EXPECTED_DIP_SCALE + SCROLL_X, result.right, 0);
        assertEquals(60 * EXPECTED_DIP_SCALE + SCROLL_Y, result.bottom, 0);

        result = formData.mFields.get(1).getBoundsInContainerViewCoordinates();
        assertEquals(20 * EXPECTED_DIP_SCALE + SCROLL_X, result.left, 0);
        assertEquals(100 * EXPECTED_DIP_SCALE + SCROLL_Y, result.top, 0);
        assertEquals(400 * EXPECTED_DIP_SCALE + SCROLL_X, result.right, 0);
        assertEquals(200 * EXPECTED_DIP_SCALE + SCROLL_Y, result.bottom, 0);
    }

    @Test
    public void testTransformToWindowBounds() {
        RectF source = new RectF(10, 20, 300, 400);
        final int offsetY = 10;
        Rect result = mAutofillProvider.transformToWindowBoundsWithOffsetY(source, offsetY);
        assertEquals(10 * EXPECTED_DIP_SCALE + LOCATION_X, result.left, 0);
        assertEquals(20 * EXPECTED_DIP_SCALE + LOCATION_Y + offsetY, result.top, 0);
        assertEquals(300 * EXPECTED_DIP_SCALE + LOCATION_X, result.right, 0);
        assertEquals(400 * EXPECTED_DIP_SCALE + LOCATION_Y + offsetY, result.bottom, 0);
    }

    /**
     * Test that AutofillProvider#autofill() does not modify the AutofillProvider#isAutofilled()
     * state of unrelated fields.
     */
    @Test
    public void testAutofillDoesNotResetUnrelatedAutofillState() {
        FormFieldDataBuilder field1Builder = new FormFieldDataBuilder();
        field1Builder.mIsAutofilled = true;
        FormFieldDataBuilder field2Builder = new FormFieldDataBuilder();
        field2Builder.mIsAutofilled = false;
        FormFieldDataBuilder field3Builder = new FormFieldDataBuilder();
        field3Builder.mIsAutofilled = false;
        FormData formData =
                new FormData(
                        /* sessionId= */ 123,
                        /* name= */ null,
                        /* host= */ null,
                        Arrays.asList(
                                field1Builder.build(),
                                field2Builder.build(),
                                field3Builder.build()));

        mAutofillProvider.startAutofillSession(
                formData,
                /* focus= */ 1,
                /* x= */ 0,
                /* y= */ 0,
                /* width= */ 0,
                /* height= */ 0,
                /* hasServerPrediction= */ false);

        assertTrue(formData.mFields.get(0).isAutofilled());
        assertFalse(formData.mFields.get(1).isAutofilled());
        assertFalse(formData.mFields.get(2).isAutofilled());

        SparseArray fillResult = new SparseArray(2);
        fillResult.put(mFocusVirtualId, AutofillValue.forText("text"));
        mAutofillProvider.autofill(fillResult);

        // The field at index 1 is autofilled. The autofill state of the other fields should be
        // unchanged.
        assertTrue(formData.mFields.get(0).isAutofilled());
        assertTrue(formData.mFields.get(1).isAutofilled());
        assertFalse(formData.mFields.get(2).isAutofilled());
    }

    @Test
    @Config(minSdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    public void testSendingPrefillRequestUsesCorrectHints() {
        FormFieldDataBuilder field1Builder = new FormFieldDataBuilder();
        field1Builder.mServerPredictions = new String[] {"NAME_FIRST", "NAME_LAST"};
        FormData formData =
                new FormData(123, null, null, Collections.singletonList(field1Builder.build()));

        mAutofillProvider.sendPrefillRequest(formData);
        // Creating a new request here shouldn't affect the results, that's better than saving the
        // prefill request in the provider.
        PrefillRequest randomRequest = new PrefillRequest(formData);
        SparseArray<VirtualViewFillInfo> expectedInfos = randomRequest.getPrefillHints();

        assertEquals(
                expectedInfos.valueAt(0).getAutofillHints(),
                mPrefillRequestInfos.valueAt(0).getAutofillHints());
    }

    @Test
    @Config(minSdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    public void testStartSessionWithPrefillRequestWithShowingBottomSheet() {
        int focus = 1;
        int sessionId = 123;
        int virtualId = FormData.toFieldVirtualId(sessionId, (short) focus);
        FormData formData = setupPrefillRequest(sessionId);
        simulateOnProvideAutofillStructure();
        when(mAutofillManager.showAutofillDialog(any(), eq(virtualId))).thenReturn(true);
        mAutofillProvider.startAutofillSession(
                formData,
                focus,
                /* x= */ 0,
                /* y= */ 0,
                /* width= */ 0,
                /* height= */ 0,
                /* hasServerPrediction= */ false);

        // showAutofillDialog should be called so it has to hold the correct virtualId.
        assertEquals(mDialogVirtualId, virtualId);
        // notifyVirtualViewEntered shouldn't be called so this has to be unset.
        assertEquals(mFocusVirtualId, 0);

        verify(mNativeMock)
                .onShowBottomSheetResult(
                        mMockedNativeAndroidAutofillProvider,
                        /* isShown= */ true,
                        /* provided_structure= */ true);
    }

    @Test
    @Config(minSdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    public void testStartSessionWithPrefillRequestWithoutShowingBottomSheet() {
        int focus = 1;
        int sessionId = 123;
        int virtualId = FormData.toFieldVirtualId(sessionId, (short) focus);
        FormData formData = setupPrefillRequest(sessionId);
        simulateOnProvideAutofillStructure();
        when(mAutofillManager.showAutofillDialog(any(), eq(virtualId))).thenReturn(false);

        mAutofillProvider.startAutofillSession(
                formData,
                focus,
                /* x= */ 0,
                /* y= */ 0,
                /* width= */ 0,
                /* height= */ 0,
                /* hasServerPrediction= */ false);

        // shouldAutofillDialog returns false so we call notifyVirtualViewEntered as well and both
        // of them will have the correct virtualId.
        assertEquals(mDialogVirtualId, virtualId);
        assertEquals(mFocusVirtualId, virtualId);

        verify(mNativeMock)
                .onShowBottomSheetResult(
                        mMockedNativeAndroidAutofillProvider,
                        /* isShown= */ false,
                        /* providedAutofillStructure= */ true);
    }

    @Test
    @Config(minSdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    public void
            testStartSessionWithPrefillRequestWithoutShowingBottomSheetAndNoAutofillStructure() {
        int focus = 1;
        int sessionId = 123;
        int virtualId = FormData.toFieldVirtualId(sessionId, (short) focus);
        FormData formData = setupPrefillRequest(sessionId);
        when(mAutofillManager.showAutofillDialog(any(), eq(virtualId))).thenReturn(false);

        mAutofillProvider.startAutofillSession(
                formData,
                focus,
                /* x= */ 0,
                /* y= */ 0,
                /* width= */ 0,
                /* height= */ 0,
                /* hasServerPrediction= */ false);

        // shouldAutofillDialog returns false so we call notifyVirtualViewEntered as well and both
        // of them will have the correct virtualId.
        assertEquals(mDialogVirtualId, virtualId);
        assertEquals(mFocusVirtualId, virtualId);

        verify(mNativeMock)
                .onShowBottomSheetResult(
                        mMockedNativeAndroidAutofillProvider,
                        /* isShown= */ false,
                        /* providedAutofillStructure= */ false);
    }

    @Test
    @Config(minSdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    public void testStartSessionWithDifferentSessionIdThanPrefillRequest() {
        int focus = 1;
        int prefillSessionId = 123;
        int newSessionId = 456;
        int virtualId = FormData.toFieldVirtualId(prefillSessionId, (short) focus);
        FormData formData = setupPrefillRequest(prefillSessionId);
        when(mAutofillManager.showAutofillDialog(any(), eq(virtualId))).thenReturn(false);

        FormData newFormData =
                new FormData(newSessionId, /* name= */ null, /* host= */ null, formData.mFields);
        mAutofillProvider.startAutofillSession(
                newFormData,
                focus,
                /* x= */ 0,
                /* y= */ 0,
                /* width= */ 0,
                /* height= */ 0,
                /* hasServerPrediction= */ false);

        // showAutofillDialog shouldn't be called so this has to be 0.
        assertEquals(mDialogVirtualId, 0);
        // notifyVirtualViewEntered should be called so this has to hold the correct virtualId.
        assertEquals(mFocusVirtualId, FormData.toFieldVirtualId(newSessionId, (short) focus));

        verify(mNativeMock, never()).onShowBottomSheetResult(anyLong(), anyBoolean(), anyBoolean());
    }

    FormData setupPrefillRequest(int sessionId) {
        FormFieldDataBuilder field1Builder = new FormFieldDataBuilder();
        field1Builder.mBounds =
                new RectF(/* left= */ 10, /* top= */ 20, /* right= */ 300, /* bottom= */ 60);
        FormFieldDataBuilder field2Builder = new FormFieldDataBuilder();
        field2Builder.mBounds =
                new RectF(/* left= */ 20, /* top= */ 100, /* right= */ 400, /* bottom= */ 200);

        FormData formData =
                new FormData(
                        sessionId,
                        /* name= */ null,
                        /* host= */ null,
                        Arrays.asList(field1Builder.build(), field2Builder.build()));
        mAutofillProvider.sendPrefillRequest(formData);

        return formData;
    }

    /**
     * Simulates a call from the Android Autofill framework to the AutofillProvider to provide the
     * Autofill ViewStructure.
     */
    void simulateOnProvideAutofillStructure() {
        mAutofillProvider.onProvideAutoFillVirtualStructure(
                new TestViewStructure(), /* flags= */ 0);
    }
}