// 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; } }