org.chromium.chrome.browser.autofill.CardUnmaskPrompt.java Source code

Java tutorial

Introduction

Here is the source code for org.chromium.chrome.browser.autofill.CardUnmaskPrompt.java

Source

// Copyright 2014 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.chrome.browser.autofill;

import android.content.Context;
import android.content.DialogInterface;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Handler;
import android.support.v4.view.MarginLayoutParamsCompat;
import android.support.v4.view.ViewCompat;
import android.support.v7.app.AlertDialog;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.PopupWindow;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import android.widget.TextView;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.chrome.R;

import java.util.Calendar;

/**
 * A prompt that bugs users to enter their CVC when unmasking a Wallet instrument (credit card).
 */
public class CardUnmaskPrompt implements DialogInterface.OnDismissListener, TextWatcher, OnClickListener {
    private static CardUnmaskObserverForTest sObserverForTest;

    private final CardUnmaskPromptDelegate mDelegate;
    private final AlertDialog mDialog;
    private boolean mShouldRequestExpirationDate;

    private final View mMainView;
    private final TextView mInstructions;
    private final TextView mNoRetryErrorMessage;
    private final EditText mCardUnmaskInput;
    private final EditText mMonthInput;
    private final EditText mYearInput;
    private final View mExpirationContainer;
    private final TextView mNewCardLink;
    private final TextView mErrorMessage;
    private final CheckBox mStoreLocallyCheckbox;
    private final ImageView mStoreLocallyTooltipIcon;
    private PopupWindow mStoreLocallyTooltipPopup;
    private final ViewGroup mControlsContainer;
    private final View mVerificationOverlay;
    private final ProgressBar mVerificationProgressBar;
    private final TextView mVerificationView;
    private final long mSuccessMessageDurationMilliseconds;

    private int mThisYear;
    private int mThisMonth;
    private boolean mValidationWaitsForCalendarTask;

    /**
     * An interface to handle the interaction with an CardUnmaskPrompt object.
     */
    public interface CardUnmaskPromptDelegate {
        /**
         * Called when the dialog has been dismissed.
         */
        void dismissed();

        /**
         * Returns whether |userResponse| represents a valid value.
         * @param userResponse A CVC entered by the user.
         */
        boolean checkUserInputValidity(String userResponse);

        /**
         * Called when the user has entered a value and pressed "verify".
         * @param userResponse The value the user entered (a CVC), or an empty string if the
         *        user canceled.
         * @param month The value the user selected for expiration month, if any.
         * @param year The value the user selected for expiration month, if any.
         * @param shouldStoreLocally The state of the "Save locally?" checkbox at the time.
         */
        void onUserInput(String cvc, String month, String year, boolean shouldStoreLocally);

        /**
         * Called when the "New card?" link has been clicked.
         * The controller will call update() in response.
         */
        void onNewCardLinkClicked();
    }

    /**
     * A test-only observer for the unmasking prompt.
     */
    public interface CardUnmaskObserverForTest {
        /**
         * Called when typing the CVC input is possible.
         */
        void onCardUnmaskPromptReadyForInput(CardUnmaskPrompt prompt);

        /**
         * Called when clicking "Verify" or "Continue" (the positive button) is possible.
         */
        void onCardUnmaskPromptReadyToUnmask(CardUnmaskPrompt prompt);
    }

    public CardUnmaskPrompt(Context context, CardUnmaskPromptDelegate delegate, String title, String instructions,
            String confirmButtonLabel, int drawableId, boolean shouldRequestExpirationDate, boolean canStoreLocally,
            boolean defaultToStoringLocally, long successMessageDurationMilliseconds) {
        mDelegate = delegate;

        LayoutInflater inflater = LayoutInflater.from(context);
        View v = inflater.inflate(R.layout.autofill_card_unmask_prompt, null);
        mInstructions = (TextView) v.findViewById(R.id.instructions);
        mInstructions.setText(instructions);

        mMainView = v;
        mNoRetryErrorMessage = (TextView) v.findViewById(R.id.no_retry_error_message);
        mCardUnmaskInput = (EditText) v.findViewById(R.id.card_unmask_input);
        mMonthInput = (EditText) v.findViewById(R.id.expiration_month);
        mYearInput = (EditText) v.findViewById(R.id.expiration_year);
        mExpirationContainer = v.findViewById(R.id.expiration_container);
        mNewCardLink = (TextView) v.findViewById(R.id.new_card_link);
        mNewCardLink.setOnClickListener(this);
        mErrorMessage = (TextView) v.findViewById(R.id.error_message);
        mStoreLocallyCheckbox = (CheckBox) v.findViewById(R.id.store_locally_checkbox);
        mStoreLocallyCheckbox.setChecked(canStoreLocally && defaultToStoringLocally);
        mStoreLocallyTooltipIcon = (ImageView) v.findViewById(R.id.store_locally_tooltip_icon);
        mStoreLocallyTooltipIcon.setOnClickListener(this);
        if (!canStoreLocally)
            v.findViewById(R.id.store_locally_container).setVisibility(View.GONE);
        mControlsContainer = (ViewGroup) v.findViewById(R.id.controls_container);
        mVerificationOverlay = v.findViewById(R.id.verification_overlay);
        mVerificationProgressBar = (ProgressBar) v.findViewById(R.id.verification_progress_bar);
        mVerificationView = (TextView) v.findViewById(R.id.verification_message);
        mSuccessMessageDurationMilliseconds = successMessageDurationMilliseconds;
        ((ImageView) v.findViewById(R.id.cvc_hint_image)).setImageResource(drawableId);

        mDialog = new AlertDialog.Builder(context, R.style.AlertDialogTheme).setTitle(title).setView(v)
                .setNegativeButton(R.string.cancel, null).setPositiveButton(confirmButtonLabel, null).create();
        mDialog.setOnDismissListener(this);

        mShouldRequestExpirationDate = shouldRequestExpirationDate;
        mThisYear = -1;
        mThisMonth = -1;
        if (mShouldRequestExpirationDate)
            new CalendarTask().execute();
    }

    /**
     * Avoids disk reads for timezone when getting the default instance of Calendar.
     */
    private class CalendarTask extends AsyncTask<Void, Void, Calendar> {
        @Override
        protected Calendar doInBackground(Void... unused) {
            return Calendar.getInstance();
        }

        @Override
        protected void onPostExecute(Calendar result) {
            mThisYear = result.get(Calendar.YEAR);
            mThisMonth = result.get(Calendar.MONTH) + 1;
            if (mValidationWaitsForCalendarTask)
                validate();
        }
    }

    public void show() {
        mDialog.show();

        showExpirationDateInputsInputs();

        // Override the View.OnClickListener so that pressing the positive button doesn't dismiss
        // the dialog.
        Button verifyButton = mDialog.getButton(AlertDialog.BUTTON_POSITIVE);
        verifyButton.setEnabled(false);
        verifyButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                mDelegate.onUserInput(mCardUnmaskInput.getText().toString(), mMonthInput.getText().toString(),
                        Integer.toString(getFourDigitYear()),
                        mStoreLocallyCheckbox != null && mStoreLocallyCheckbox.isChecked());
            }
        });

        mCardUnmaskInput.addTextChangedListener(this);
        mCardUnmaskInput.post(new Runnable() {
            @Override
            public void run() {
                setInitialFocus();
            }
        });
    }

    public void update(String title, String instructions, boolean shouldRequestExpirationDate) {
        assert mDialog.isShowing();
        mDialog.setTitle(title);
        mInstructions.setText(instructions);
        mShouldRequestExpirationDate = shouldRequestExpirationDate;
        showExpirationDateInputsInputs();
    }

    public void dismiss() {
        mDialog.dismiss();
    }

    public void disableAndWaitForVerification() {
        setInputsEnabled(false);
        setOverlayVisibility(View.VISIBLE);
        mVerificationProgressBar.setVisibility(View.VISIBLE);
        mVerificationView.setText(R.string.autofill_card_unmask_verification_in_progress);
        mVerificationView.announceForAccessibility(mVerificationView.getText());
        setInputError(null);
    }

    public void verificationFinished(String errorMessage, boolean allowRetry) {
        if (errorMessage != null) {
            setOverlayVisibility(View.GONE);
            if (allowRetry) {
                setInputError(errorMessage);
                setInputsEnabled(true);
                setInitialFocus();

                if (!mShouldRequestExpirationDate)
                    mNewCardLink.setVisibility(View.VISIBLE);
            } else {
                setInputError(null);
                setNoRetryError(errorMessage);
            }
        } else {
            Runnable dismissRunnable = new Runnable() {
                @Override
                public void run() {
                    dismiss();
                }
            };
            if (mSuccessMessageDurationMilliseconds > 0) {
                mVerificationProgressBar.setVisibility(View.GONE);
                mDialog.findViewById(R.id.verification_success).setVisibility(View.VISIBLE);
                mVerificationView.setText(R.string.autofill_card_unmask_verification_success);
                mVerificationView.announceForAccessibility(mVerificationView.getText());
                new Handler().postDelayed(dismissRunnable, mSuccessMessageDurationMilliseconds);
            } else {
                new Handler().post(dismissRunnable);
            }
        }
    }

    @Override
    public void onDismiss(DialogInterface dialog) {
        mDelegate.dismissed();
    }

    @Override
    public void afterTextChanged(Editable s) {
        validate();
    }

    private void validate() {
        Button positiveButton = mDialog.getButton(AlertDialog.BUTTON_POSITIVE);
        positiveButton.setEnabled(areInputsValid());
        if (positiveButton.isEnabled() && sObserverForTest != null) {
            sObserverForTest.onCardUnmaskPromptReadyToUnmask(this);
        }
    }

    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
    }

    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {
    }

    @Override
    public void onClick(View v) {
        if (v == mStoreLocallyTooltipIcon) {
            onTooltipIconClicked();
        } else {
            assert v == mNewCardLink;
            onNewCardLinkClicked();
        }
    }

    private void showExpirationDateInputsInputs() {
        if (!mShouldRequestExpirationDate || mExpirationContainer.getVisibility() == View.VISIBLE) {
            return;
        }

        mExpirationContainer.setVisibility(View.VISIBLE);
        mCardUnmaskInput.setEms(3);
        mMonthInput.addTextChangedListener(this);
        mYearInput.addTextChangedListener(this);
    }

    private void onTooltipIconClicked() {
        // Don't show the popup if there's already one showing (or one has been dismissed
        // recently). This prevents a tap on the (?) from hiding and then immediately re-showing
        // the popup.
        if (mStoreLocallyTooltipPopup != null)
            return;

        mStoreLocallyTooltipPopup = new PopupWindow(mDialog.getContext());
        TextView text = new TextView(mDialog.getContext());
        text.setText(R.string.autofill_card_unmask_prompt_storage_tooltip);
        // Width is the dialog's width less the margins and padding around the checkbox and
        // icon.
        text.setWidth(mMainView.getWidth() - ViewCompat.getPaddingStart(mStoreLocallyCheckbox)
                - ViewCompat.getPaddingEnd(mStoreLocallyTooltipIcon)
                - MarginLayoutParamsCompat
                        .getMarginStart((RelativeLayout.LayoutParams) mStoreLocallyCheckbox.getLayoutParams())
                - MarginLayoutParamsCompat
                        .getMarginEnd((RelativeLayout.LayoutParams) mStoreLocallyTooltipIcon.getLayoutParams()));
        text.setTextColor(Color.WHITE);
        Resources resources = mDialog.getContext().getResources();
        int hPadding = resources.getDimensionPixelSize(R.dimen.autofill_card_unmask_tooltip_horizontal_padding);
        int vPadding = resources.getDimensionPixelSize(R.dimen.autofill_card_unmask_tooltip_vertical_padding);
        text.setPadding(hPadding, vPadding, hPadding, vPadding);

        mStoreLocallyTooltipPopup.setContentView(text);
        mStoreLocallyTooltipPopup.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
        mStoreLocallyTooltipPopup.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT);
        mStoreLocallyTooltipPopup.setOutsideTouchable(true);
        mStoreLocallyTooltipPopup.setBackgroundDrawable(
                ApiCompatibilityUtils.getDrawable(resources, R.drawable.store_locally_tooltip_background));
        mStoreLocallyTooltipPopup.setOnDismissListener(new PopupWindow.OnDismissListener() {
            @Override
            public void onDismiss() {
                Handler h = new Handler();
                h.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        mStoreLocallyTooltipPopup = null;
                    }
                }, 200);
            }
        });
        mStoreLocallyTooltipPopup.showAsDropDown(mStoreLocallyCheckbox,
                ViewCompat.getPaddingStart(mStoreLocallyCheckbox), 0);
        text.announceForAccessibility(text.getText());
    }

    private void onNewCardLinkClicked() {
        mDelegate.onNewCardLinkClicked();
        assert mShouldRequestExpirationDate;
        mNewCardLink.setVisibility(View.GONE);
        mCardUnmaskInput.setText(null);
        setInputError(null);
        mMonthInput.requestFocus();
    }

    private void setInitialFocus() {
        InputMethodManager imm = (InputMethodManager) mDialog.getContext()
                .getSystemService(Context.INPUT_METHOD_SERVICE);
        View view = mShouldRequestExpirationDate ? mMonthInput : mCardUnmaskInput;
        imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT);
        view.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
        if (sObserverForTest != null) {
            sObserverForTest.onCardUnmaskPromptReadyForInput(this);
        }
    }

    private boolean areInputsValid() {
        if (mShouldRequestExpirationDate) {
            if (mThisYear == -1 || mThisMonth == -1) {
                mValidationWaitsForCalendarTask = true;
                return false;
            }

            int month = -1;
            try {
                month = Integer.parseInt(mMonthInput.getText().toString());
                if (month < 1 || month > 12)
                    return false;
            } catch (NumberFormatException e) {
                return false;
            }

            int year = getFourDigitYear();
            if (year < mThisYear || year > mThisYear + 10)
                return false;

            if (year == mThisYear && month < mThisMonth)
                return false;
        }
        return mDelegate.checkUserInputValidity(mCardUnmaskInput.getText().toString());
    }

    /**
     * Sets the enabled state of the main contents, and hides or shows the verification overlay.
     * @param enabled True if the inputs should be useable, false if the verification overlay
     *        obscures them.
     */
    private void setInputsEnabled(boolean enabled) {
        mCardUnmaskInput.setEnabled(enabled);
        mMonthInput.setEnabled(enabled);
        mYearInput.setEnabled(enabled);
        mStoreLocallyCheckbox.setEnabled(enabled);
        mDialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(enabled);
    }

    /**
     * Updates the verification overlay and main contents such that the overlay has |visibility|.
     * @param visibility A View visibility enumeration value.
     */
    private void setOverlayVisibility(int visibility) {
        mVerificationOverlay.setVisibility(visibility);
        mControlsContainer.setAlpha(1f);
        boolean contentsShowing = visibility == View.GONE;
        if (!contentsShowing) {
            int durationMs = 250;
            mVerificationOverlay.setAlpha(0f);
            mVerificationOverlay.animate().alpha(1f).setDuration(durationMs);
            mControlsContainer.animate().alpha(0f).setDuration(durationMs);
        }
        ViewCompat.setImportantForAccessibility(mControlsContainer,
                contentsShowing ? View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
                        : View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
        mControlsContainer.setDescendantFocusability(
                contentsShowing ? ViewGroup.FOCUS_BEFORE_DESCENDANTS : ViewGroup.FOCUS_BLOCK_DESCENDANTS);
    }

    /**
     * Sets the error message on the cvc input.
     * @param message The error message to show, or null if the error state should be cleared.
     */
    private void setInputError(String message) {
        mErrorMessage.setText(message);
        mErrorMessage.setVisibility(message == null ? View.GONE : View.VISIBLE);

        // A null message is passed in during card verification, which also makes an announcement.
        // Announcing twice in a row may cancel the first announcement.
        if (message != null) {
            mErrorMessage.announceForAccessibility(message);
        }

        // The rest of this code makes L-specific assumptions about the background being used to
        // draw the TextInput.
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
            return;

        ColorFilter filter = null;
        if (message != null) {
            filter = new PorterDuffColorFilter(ApiCompatibilityUtils.getColor(mDialog.getContext().getResources(),
                    R.color.input_underline_error_color), PorterDuff.Mode.SRC_IN);
        }

        // TODO(estade): it would be nicer if the error were specific enough to tell us which input
        // was invalid.
        updateColorForInput(mCardUnmaskInput, filter);
        updateColorForInput(mMonthInput, filter);
        updateColorForInput(mYearInput, filter);
    }

    /**
     * Displays an error that indicates the user can't retry.
     */
    private void setNoRetryError(String message) {
        mNoRetryErrorMessage.setText(message);
        mNoRetryErrorMessage.setVisibility(View.VISIBLE);
        mNoRetryErrorMessage.announceForAccessibility(message);
    }

    /**
     * Sets the stroke color for the given input.
     * @param input The input to modify.
     * @param filter The color filter to apply to the background.
     */
    private void updateColorForInput(EditText input, ColorFilter filter) {
        input.getBackground().mutate().setColorFilter(filter);
    }

    /**
     * Returns the expiration year the user entered.
     * Two digit values (such as 17) will be converted to 4 digit years (such as 2017).
     * Returns -1 if the input is empty or otherwise not a valid year.
     */
    private int getFourDigitYear() {
        try {
            int year = Integer.parseInt(mYearInput.getText().toString());
            if (year < 0)
                return -1;
            if (year < 100)
                year += mThisYear - mThisYear % 100;
            return year;
        } catch (NumberFormatException e) {
            return -1;
        }
    }

    @VisibleForTesting
    public static void setObserverForTest(CardUnmaskObserverForTest observerForTest) {
        sObserverForTest = observerForTest;
    }

    @VisibleForTesting
    public AlertDialog getDialogForTest() {
        return mDialog;
    }
}