// Copyright 2021 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.chrome.browser.ui.android.webid;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.os.SystemClock;
import android.text.TextUtils;
import android.view.View;
import android.view.accessibility.AccessibilityEvent;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.Callback;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.blink.mojom.RpContext;
import org.chromium.blink.mojom.RpMode;
import org.chromium.chrome.browser.customtabs.CustomTabActivity;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.ui.android.webid.AccountSelectionProperties.AccountProperties;
import org.chromium.chrome.browser.ui.android.webid.AccountSelectionProperties.AddAccountButtonProperties;
import org.chromium.chrome.browser.ui.android.webid.AccountSelectionProperties.ContinueButtonProperties;
import org.chromium.chrome.browser.ui.android.webid.AccountSelectionProperties.DataSharingConsentProperties;
import org.chromium.chrome.browser.ui.android.webid.AccountSelectionProperties.ErrorProperties;
import org.chromium.chrome.browser.ui.android.webid.AccountSelectionProperties.HeaderProperties;
import org.chromium.chrome.browser.ui.android.webid.AccountSelectionProperties.HeaderProperties.HeaderType;
import org.chromium.chrome.browser.ui.android.webid.AccountSelectionProperties.IdpSignInProperties;
import org.chromium.chrome.browser.ui.android.webid.AccountSelectionProperties.ItemProperties;
import org.chromium.chrome.browser.ui.android.webid.data.Account;
import org.chromium.chrome.browser.ui.android.webid.data.ClientIdMetadata;
import org.chromium.chrome.browser.ui.android.webid.data.IdentityCredentialTokenError;
import org.chromium.chrome.browser.ui.android.webid.data.IdentityProviderData;
import org.chromium.chrome.browser.ui.android.webid.data.IdentityProviderMetadata;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetContent;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController.SheetState;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetObserver;
import org.chromium.components.browser_ui.bottomsheet.EmptyBottomSheetObserver;
import org.chromium.components.image_fetcher.ImageFetcher;
import org.chromium.content.webid.IdentityRequestDialogDismissReason;
import org.chromium.content.webid.IdentityRequestDialogLinkType;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.ui.KeyboardVisibilityDelegate;
import org.chromium.ui.KeyboardVisibilityDelegate.KeyboardVisibilityListener;
import org.chromium.ui.modelutil.MVCListAdapter.ListItem;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.url.GURL;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Arrays;
import java.util.List;
/**
* Contains the logic for the AccountSelection component. It sets the state of the model and reacts
* to events like clicks.
*/
class AccountSelectionMediator {
/**
* The following integers are used for histograms. Do not remove or modify existing values, but
* you may add new values at the end and increase NUM_ENTRIES. This enum should be kept in sync
* with SheetType in chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.h as
* well as with FedCmSheetType in tools/metrics/histograms/enums.xml.
*/
@IntDef({
SheetType.ACCOUNT_SELECTION,
SheetType.VERIFYING,
SheetType.AUTO_REAUTHN,
SheetType.SIGN_IN_TO_IDP_STATIC,
SheetType.SIGN_IN_ERROR,
SheetType.LOADING,
SheetType.NUM_ENTRIES
})
@Retention(RetentionPolicy.SOURCE)
private @interface SheetType {
int ACCOUNT_SELECTION = 0;
int VERIFYING = 1;
int AUTO_REAUTHN = 2;
int SIGN_IN_TO_IDP_STATIC = 3;
int SIGN_IN_ERROR = 4;
int LOADING = 5;
int NUM_ENTRIES = 6;
}
/**
* The following integers are used for histograms. Do not remove or modify existing values, but
* you may add new values at the end and increase NUM_ENTRIES. This enum should be kept in sync
* with AccountChooserResult in
* chrome/browser/ui/views/webid/fedcm_account_selection_view_desktop.h as well as with
* FedCmAccountChooserResult in tools/metrics/histograms/enums.xml.
*/
@IntDef({
AccountChooserResult.ACCOUNT_ROW,
AccountChooserResult.CANCEL_BUTTON,
AccountChooserResult.USE_OTHER_ACCOUNT_BUTTON,
AccountChooserResult.TAB_CLOSED,
AccountChooserResult.SWIPE,
AccountChooserResult.BACK_PRESS,
AccountChooserResult.TAP_SCRIM,
AccountChooserResult.NUM_ENTRIES
})
@Retention(RetentionPolicy.SOURCE)
@VisibleForTesting
@interface AccountChooserResult {
int ACCOUNT_ROW = 0;
int CANCEL_BUTTON = 1;
int USE_OTHER_ACCOUNT_BUTTON = 2;
int TAB_CLOSED = 3;
int SWIPE = 4;
int BACK_PRESS = 5;
int TAP_SCRIM = 6;
int NUM_ENTRIES = 7;
}
private boolean mRegisteredObservers;
private boolean mWasDismissed;
// Keeps track of the last bottom sheet seen by the BottomSheetObserver. Used to know whether a
// sheet state change affects the BottomSheet owned by this object or not.
private BottomSheetContent mLastSheetSeen;
@VisibleForTesting private final Tab mTab;
private final AccountSelectionComponent.Delegate mDelegate;
private final PropertyModel mModel;
private final ModelList mSheetAccountItems;
private final @Px int mDesiredAvatarSize;
private final @RpMode.EnumType int mRpMode;
private final BottomSheetController mBottomSheetController;
private final AccountSelectionBottomSheetContent mBottomSheetContent;
private final BottomSheetObserver mBottomSheetObserver;
private final TabObserver mTabObserver;
// Amount of time during which we ignore inputs. Note that this is timed from when we invoke the
// methods to show the accounts, so it does include any time spent animating the sheet into
// view.
public static final long POTENTIALLY_UNINTENDED_INPUT_THRESHOLD = 500;
private HeaderType mHeaderType;
private String mRpForDisplay;
private String mIdpForDisplay;
private IdentityProviderMetadata mIdpMetadata;
private Bitmap mIdpBrandIcon;
private Bitmap mRpBrandIcon;
private ClientIdMetadata mClientMetadata;
private boolean mIsAutoReauthn;
private @RpContext.EnumType int mRpContext;
private IdentityCredentialTokenError mError;
private boolean mRequestPermission;
private ImageFetcher mImageFetcher;
// All of the user's accounts.
private List<Account> mAccounts;
// The account that the user has selected.
private Account mSelectedAccount;
// Stores the value of SystemClock.elapsedRealtime() at the time in which the accounts are shown
// to the user.
private long mComponentShowTime;
// Whether there is an open modal dialog. When a modal dialog is opened, this
// mediator should not display any accounts until such dialog is closed.
private boolean mIsModalDialogOpen;
// View to explicitly focus on for screen reader accessibility purposes.
private View mFocusView;
// The current state of the account chooser if opened for metrics purposes. Histogram is only
// recorded for button mode.
private @Nullable Integer mAccountChooserState;
private KeyboardVisibilityListener mKeyboardVisibilityListener =
new KeyboardVisibilityListener() {
@Override
public void keyboardVisibilityChanged(boolean isShowing) {
if (isShowing) {
mBottomSheetController.hideContent(mBottomSheetContent, true);
} else if (mTab.isUserInteractable()) {
showContent();
}
}
};
AccountSelectionMediator(
Tab tab,
AccountSelectionComponent.Delegate delegate,
PropertyModel model,
ModelList sheetAccountItems,
BottomSheetController bottomSheetController,
AccountSelectionBottomSheetContent bottomSheetContent,
ImageFetcher imageFetcher,
@Px int desiredAvatarSize,
@RpMode.EnumType int rpMode) {
assert tab != null;
mTab = tab;
assert delegate != null;
mDelegate = delegate;
mModel = model;
mSheetAccountItems = sheetAccountItems;
mImageFetcher = imageFetcher;
mDesiredAvatarSize = desiredAvatarSize;
mRpMode = rpMode;
mBottomSheetController = bottomSheetController;
mBottomSheetContent = bottomSheetContent;
mLastSheetSeen = mBottomSheetContent;
mBottomSheetObserver =
new EmptyBottomSheetObserver() {
// Sends focus events to the relevant views for accessibility.
// TODO(crbug.com/40262629): Add tests for TalkBack on FedCM.
private void focusForAccessibility() {
View contentView =
mBottomSheetController.getCurrentSheetContent().getContentView();
assert contentView != null;
View continueButton =
contentView.findViewById(R.id.account_selection_continue_btn);
boolean isSingleAccountChooser = mAccounts != null && mAccounts.size() == 1;
View focusView =
mFocusView != null
? mFocusView
: continueButton != null
&& continueButton.isShown()
&& !isSingleAccountChooser
&& getSheetType()
== SheetType.ACCOUNT_SELECTION
? continueButton
: contentView.findViewById(R.id.header);
if (focusView == null) return;
focusView.requestFocus();
focusView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
mFocusView = null;
}
@Override
public void onSheetStateChanged(@SheetState int state, int reason) {
if (mLastSheetSeen != mBottomSheetContent) return;
if (mWasDismissed) return;
if (state == SheetState.HIDDEN) {
// BottomSheetController.StateChangeReason.NONE happens for instance
// when the user opens the tab switcher or when the user leaves
// Chrome. We do not want to dismiss in those cases.
if (reason == BottomSheetController.StateChangeReason.NONE) {
mBottomSheetController.hideContent(mBottomSheetContent, true);
} else {
super.onSheetClosed(reason);
@IdentityRequestDialogDismissReason
int dismissReason = IdentityRequestDialogDismissReason.OTHER;
if (reason == BottomSheetController.StateChangeReason.SWIPE) {
dismissReason = IdentityRequestDialogDismissReason.SWIPE;
// mAccountChooserState is not null only if we want to record
// this metric, that is, a button mode explicit sign-in account
// chooser was shown.
mAccountChooserState =
mAccountChooserState != null
? AccountChooserResult.SWIPE
: null;
} else if (reason
== BottomSheetController.StateChangeReason.BACK_PRESS) {
dismissReason = IdentityRequestDialogDismissReason.BACK_PRESS;
// mAccountChooserState is not null only if we want to record
// this metric, that is, a button mode explicit sign-in account
// chooser was shown.
mAccountChooserState =
mAccountChooserState != null
? AccountChooserResult.BACK_PRESS
: null;
} else if (reason
== BottomSheetController.StateChangeReason.TAP_SCRIM) {
dismissReason = IdentityRequestDialogDismissReason.TAP_SCRIM;
// mAccountChooserState is not null only if we want to record
// this metric, that is, a button mode explicit sign-in account
// chooser was shown.
mAccountChooserState =
mAccountChooserState != null
? AccountChooserResult.TAP_SCRIM
: null;
}
onDismissed(dismissReason);
}
return;
}
if (state != SheetState.FULL) return;
// The bottom sheet programmatically requests focuses for accessibility when
// its contents are changed. If we call focusForAccessibility prior to
// onSheetStateChanged, the bottom sheet announcement would override the
// title or continue button announcement. Hence, focusForAccessibility is
// called here after the bottom sheet's focus-taking actions.
focusForAccessibility();
}
@Override
public void onSheetContentChanged(BottomSheetContent bottomSheet) {
// Keep track of the latest sheet seen. Since this method is invoked before
// onSheetStateChanged() when the sheet is swiped out, we do not clear
// |mLastSheetSeen| if |bottomSheet| is null.
if (bottomSheet != null) {
mLastSheetSeen = bottomSheet;
}
}
};
mTabObserver =
new EmptyTabObserver() {
@Override
public void onDidStartNavigationInPrimaryMainFrame(
Tab tab, NavigationHandle navigationHandle) {
assert tab == mTab;
onDismissed(IdentityRequestDialogDismissReason.OTHER);
}
@Override
public void onInteractabilityChanged(Tab tab, boolean isInteractable) {
assert tab == mTab;
// |isInteractable| is true when the tab is not hidden and its view is
// attached to the window. We use this method instead of onShown() and
// onHidden() because this one is correctly invoked when the user enters
// tab switcher (the current tab is no longer interactable in this case).
if (isInteractable) {
showContent();
} else {
mBottomSheetController.hideContent(mBottomSheetContent, false);
}
}
};
}
private void setFocusView(View focusView) {
// If focus view has already been set, we do not override it. This is because we bind views
// in order of most important to least important for accessibility so the first call to this
// method should be from the most important element that should take focus.
if (mFocusView != null) return;
mFocusView = focusView;
}
private void updateBackPressBehavior() {
mBottomSheetContent.setCustomBackPressBehavior(
!mWasDismissed
&& ((mSelectedAccount != null
&& mAccounts.size() != 1
&& mHeaderType != HeaderType.VERIFY)
|| mHeaderType == HeaderType.REQUEST_PERMISSION)
? this::handleBackPress
: null);
}
private void handleBackPress() {
mSelectedAccount = null;
showAccountsInternal(/* newAccountsIdp= */ null);
}
private PropertyModel createHeaderItem(
HeaderType headerType,
String rpForDisplay,
String idpForDisplay,
@RpContext.EnumType int rpContext) {
Runnable closeOnClickRunnable =
() -> {
onDismissed(IdentityRequestDialogDismissReason.CLOSE_BUTTON);
RecordHistogram.recordBooleanHistogram(
"Blink.FedCm.CloseVerifySheet.Android",
mHeaderType == HeaderType.VERIFY);
RecordHistogram.recordEnumeratedHistogram(
"Blink.FedCm.ClosedSheetType.Android",
getSheetType(),
SheetType.NUM_ENTRIES);
};
return new PropertyModel.Builder(HeaderProperties.ALL_KEYS)
.with(HeaderProperties.IDP_BRAND_ICON, mIdpBrandIcon)
.with(HeaderProperties.RP_BRAND_ICON, mRpBrandIcon)
.with(HeaderProperties.CLOSE_ON_CLICK_LISTENER, closeOnClickRunnable)
.with(HeaderProperties.IDP_FOR_DISPLAY, idpForDisplay)
.with(HeaderProperties.RP_FOR_DISPLAY, rpForDisplay)
.with(HeaderProperties.TYPE, headerType)
.with(HeaderProperties.RP_CONTEXT, rpContext)
.with(HeaderProperties.RP_MODE, mRpMode)
.with(
HeaderProperties.IS_MULTIPLE_ACCOUNT_CHOOSER,
mSelectedAccount == null && mAccounts != null && mAccounts.size() > 1)
.with(HeaderProperties.SET_FOCUS_VIEW_CALLBACK, this::setFocusView)
.build();
}
private int getSheetType() {
switch (mHeaderType) {
case SIGN_IN:
case REQUEST_PERMISSION:
return SheetType.ACCOUNT_SELECTION;
case VERIFY:
return SheetType.VERIFYING;
case VERIFY_AUTO_REAUTHN:
return SheetType.AUTO_REAUTHN;
case SIGN_IN_TO_IDP_STATIC:
return SheetType.SIGN_IN_TO_IDP_STATIC;
case SIGN_IN_ERROR:
return SheetType.SIGN_IN_ERROR;
case LOADING:
return SheetType.LOADING;
}
assert false; // NOTREACHED
return SheetType.ACCOUNT_SELECTION;
}
private void updateAccounts(
String idpForDisplay,
List<Account> accounts,
boolean areAccountsClickable,
boolean showAddAccountRow) {
mSheetAccountItems.clear();
if (accounts == null) return;
// In the request permission dialog, account is shown as an account chip instead of in the
// accounts list. In the button mode verifying dialog, we do not show accounts.
if (mRpMode == RpMode.BUTTON
&& (mHeaderType == HeaderType.REQUEST_PERMISSION
|| mHeaderType == HeaderType.VERIFY
|| mHeaderType == HeaderType.VERIFY_AUTO_REAUTHN)) {
return;
}
for (Account account : accounts) {
final PropertyModel model = createAccountItem(account, areAccountsClickable);
mSheetAccountItems.add(
new ListItem(AccountSelectionProperties.ITEM_TYPE_ACCOUNT, model));
}
if (showAddAccountRow) {
final PropertyModel model = createAddAccountBtnItem();
mSheetAccountItems.add(
new ListItem(AccountSelectionProperties.ITEM_TYPE_ADD_ACCOUNT, model));
}
}
/* Returns whether an input event being processed should be ignored due to it occurring too
* close in time to the time in which the dialog was shown.
*/
private boolean shouldInputBeProcessed() {
assert mComponentShowTime != 0;
long currentTime = SystemClock.elapsedRealtime();
return currentTime - mComponentShowTime > POTENTIALLY_UNINTENDED_INPUT_THRESHOLD;
}
/* Used to show placeholder icon so that the header text wrapping does not change when the icon
* is fetched.
*/
private void showPlaceholderIcon(IdentityProviderMetadata idpMetadata) {
if (!TextUtils.isEmpty(idpMetadata.getBrandIconUrl())) {
mIdpBrandIcon = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
Canvas brandIconCanvas = new Canvas(mIdpBrandIcon);
brandIconCanvas.drawColor(Color.TRANSPARENT);
}
}
private void fetchBrandIcon(String brandIconUrl, Callback<Bitmap> callback) {
if (!TextUtils.isEmpty(brandIconUrl)) {
int brandIconIdealSize = AccountSelectionBridge.getBrandIconIdealSize(mRpMode);
ImageFetcher.Params params =
ImageFetcher.Params.createNoResizing(
new GURL(brandIconUrl),
ImageFetcher.WEB_ID_ACCOUNT_SELECTION_UMA_CLIENT_NAME,
brandIconIdealSize,
brandIconIdealSize);
mImageFetcher.fetchImage(params, callback);
}
}
private boolean isValidBrandIcon(Bitmap bitmap) {
return bitmap != null
&& bitmap.getWidth() == bitmap.getHeight()
&& bitmap.getWidth() >= AccountSelectionBridge.getBrandIconMinimumSize(mRpMode);
}
private void updateIdpBrandIcon(Bitmap bitmap) {
if (!isValidBrandIcon(bitmap)) {
return;
}
mIdpBrandIcon = bitmap;
updateHeader();
// Resizes bottom sheet to the desired height, taking the icon into account.
mBottomSheetController.expandSheet();
}
private void updateRpBrandIcon(Bitmap bitmap) {
if (!isValidBrandIcon(bitmap)) {
return;
}
mRpBrandIcon = bitmap;
updateHeader();
// Resizes bottom sheet to the desired height, taking the icon into account.
mBottomSheetController.expandSheet();
}
private void maybeRecordAccountChooserResult(int result) {
if (mAccountChooserState == null) return;
RecordHistogram.recordEnumeratedHistogram(
"Blink.FedCm.Button.AccountChooserResult", result, SheetType.NUM_ENTRIES);
mAccountChooserState = null;
}
void showVerifySheet(Account account) {
if (mHeaderType == HeaderType.SIGN_IN || mHeaderType == HeaderType.REQUEST_PERMISSION) {
mHeaderType = HeaderType.VERIFY;
updateSheet(Arrays.asList(account), /* areAccountsClickable= */ false);
updateBackPressBehavior();
} else {
// We call showVerifySheet() from updateSheet()->onAccountSelected() in this case, so do
// not invoke updateSheet() as that would cause a loop and isn't needed.
assert mHeaderType == HeaderType.VERIFY_AUTO_REAUTHN;
}
}
void showRequestPermissionSheet(Account account) {
mHeaderType = HeaderType.REQUEST_PERMISSION;
updateSheet(Arrays.asList(account), /* areAccountsClickable= */ false);
updateBackPressBehavior();
}
// Dismisses content without notifying the delegate. Should only be invoked during destruction.
void close() {
if (!mWasDismissed) dismissContent();
}
void showAccounts(
String rpForDisplay,
String idpForDisplay,
List<Account> accounts,
IdentityProviderMetadata idpMetadata,
ClientIdMetadata clientMetadata,
boolean isAutoReauthn,
@RpContext.EnumType int rpContext,
boolean requestPermission,
@Nullable IdentityProviderData newAccountsIdp) {
// On widget mode, show placeholder icon to preserve header text wrapping when icon is
// fetched.
if (mRpMode == RpMode.WIDGET) {
showPlaceholderIcon(idpMetadata);
}
mRpForDisplay = rpForDisplay;
mIdpForDisplay = idpForDisplay;
mAccounts = accounts;
mIdpMetadata = idpMetadata;
mClientMetadata = clientMetadata;
mIsAutoReauthn = isAutoReauthn;
mRpContext = rpContext;
mRequestPermission = requestPermission;
mSelectedAccount = null;
fetchBrandIcon(mIdpMetadata.getBrandIconUrl(), bitmap -> updateIdpBrandIcon(bitmap));
// RP brand icon is fetched here, but not shown until the request permission dialog.
if (mRpMode == RpMode.BUTTON) {
fetchBrandIcon(mClientMetadata.getBrandIconUrl(), bitmap -> updateRpBrandIcon(bitmap));
}
if (accounts.size() == 1 && (isAutoReauthn || !idpMetadata.supportsAddAccount())) {
mSelectedAccount = accounts.get(0);
}
showAccountsInternal(newAccountsIdp);
setComponentShowTime(SystemClock.elapsedRealtime());
}
void showFailureDialog(
String rpForDisplay,
String idpForDisplay,
IdentityProviderMetadata idpMetadata,
@RpContext.EnumType int rpContext) {
showPlaceholderIcon(idpMetadata);
mRpForDisplay = rpForDisplay;
mIdpForDisplay = idpForDisplay;
mIdpMetadata = idpMetadata;
mRpContext = rpContext;
mHeaderType = HeaderProperties.HeaderType.SIGN_IN_TO_IDP_STATIC;
updateSheet(/* accounts= */ null, /* areAccountsClickable= */ false);
setComponentShowTime(SystemClock.elapsedRealtime());
fetchBrandIcon(idpMetadata.getBrandIconUrl(), bitmap -> updateIdpBrandIcon(bitmap));
}
void showErrorDialog(
String rpForDisplay,
String idpForDisplay,
IdentityProviderMetadata idpMetadata,
@RpContext.EnumType int rpContext,
IdentityCredentialTokenError error) {
showPlaceholderIcon(idpMetadata);
mRpForDisplay = rpForDisplay;
mIdpForDisplay = idpForDisplay;
mIdpMetadata = idpMetadata;
mRpContext = rpContext;
mError = error;
mHeaderType = HeaderProperties.HeaderType.SIGN_IN_ERROR;
updateSheet(/* accounts= */ null, /* areAccountsClickable= */ false);
setComponentShowTime(SystemClock.elapsedRealtime());
fetchBrandIcon(idpMetadata.getBrandIconUrl(), bitmap -> updateIdpBrandIcon(bitmap));
}
void showLoadingDialog(
String rpForDisplay, String idpForDisplay, @RpContext.EnumType int rpContext) {
mRpForDisplay = rpForDisplay;
mIdpForDisplay = idpForDisplay;
mRpContext = rpContext;
mHeaderType = HeaderProperties.HeaderType.LOADING;
updateSheet(/* accounts= */ null, /* areAccountsClickable= */ false);
setComponentShowTime(SystemClock.elapsedRealtime());
}
void showUrl(Context context, @IdentityRequestDialogLinkType int linkType, GURL url) {
switch (linkType) {
case IdentityRequestDialogLinkType.TERMS_OF_SERVICE:
RecordHistogram.recordBooleanHistogram(
"Blink.FedCm.SignUp.TermsOfServiceClicked", true);
break;
case IdentityRequestDialogLinkType.PRIVACY_POLICY:
RecordHistogram.recordBooleanHistogram(
"Blink.FedCm.SignUp.PrivacyPolicyClicked", true);
break;
}
CustomTabActivity.showInfoPage(context, url.getSpec());
}
@VisibleForTesting
void setComponentShowTime(long componentShowTime) {
mComponentShowTime = componentShowTime;
}
@VisibleForTesting
void setImageFetcher(ImageFetcher imageFetcher) {
mImageFetcher = imageFetcher;
}
@VisibleForTesting
KeyboardVisibilityListener getKeyboardEventListener() {
return mKeyboardVisibilityListener;
}
@VisibleForTesting
TabObserver getTabObserver() {
return mTabObserver;
}
@VisibleForTesting
HeaderType getHeaderType() {
return mHeaderType;
}
private void showAccountsInternal(@Nullable IdentityProviderData newAccountsIdp) {
// TODO(crbug.com/356665527): Handle multiple newly signed-in accounts.
Account newlySignedInAccount =
newAccountsIdp != null && newAccountsIdp.getAccounts().size() == 1
? newAccountsIdp.getAccounts().get(0)
: null;
if (!mIsAutoReauthn && newlySignedInAccount != null && mRpMode == RpMode.BUTTON) {
mSelectedAccount = newlySignedInAccount;
// The browser trusted login state controls whether we'd skip the next
// dialog. One caveat: if a user was logged out of the IdP and they just
// logged in with a returning account from the LOADING state, we do not
// skip the next UI when mediation mode is `required` because there was
// not user mediation acquired yet in this case.
boolean shouldShowVerifyingSheet =
newlySignedInAccount.isBrowserTrustedSignIn()
&& mHeaderType != HeaderType.LOADING;
if (shouldShowVerifyingSheet) {
mHeaderType = HeaderType.SIGN_IN;
mDelegate.onAccountSelected(mIdpMetadata.getConfigUrl(), mSelectedAccount);
showVerifySheet(mSelectedAccount);
return;
}
// The IDP claimed login state controls whether we show disclosure text,
// if we do not skip the next dialog. Also skip when request_permission
// is false (controlled by the fields API).
boolean shouldShowRequestPermissionDialog =
!newlySignedInAccount.isSignIn() && newAccountsIdp.getRequestPermission();
if (shouldShowRequestPermissionDialog) {
showRequestPermissionSheet(mSelectedAccount);
return;
}
// Else:
// Show accounts picker which doesn't contain the disclosure text. We do not support
// request permission UI without disclosure text.
}
mHeaderType = mIsAutoReauthn ? HeaderType.VERIFY_AUTO_REAUTHN : HeaderType.SIGN_IN;
updateSheet(
mSelectedAccount != null ? Arrays.asList(mSelectedAccount) : mAccounts,
/* areAccountsClickable= */ mSelectedAccount == null);
updateBackPressBehavior();
// This is a placeholder assuming the tab containing the account chooser will be closed.
// This will be updated upon user action i.e. clicking on account row, use
// other account button or swiped down. If we do not receive any of these actions by time
// onDismissed() is called, it means our placeholder assumption is true i.e. the user has
// closed the tab.
if (mRpMode == RpMode.BUTTON && !mIsAutoReauthn) {
// If there was already an account chooser state from a previously shown account
// chooser, record the outcome and reset the state.
if (mAccountChooserState != null) {
maybeRecordAccountChooserResult(mAccountChooserState);
}
mAccountChooserState = AccountChooserResult.TAB_CLOSED;
}
}
private void updateSheet(List<Account> accounts, boolean areAccountsClickable) {
boolean supportsAddAccount =
mRpMode == RpMode.BUTTON
&& mHeaderType == HeaderType.SIGN_IN
&& areAccountsClickable
&& mIdpMetadata.supportsAddAccount();
boolean isSingleAccountChooser = accounts != null && accounts.size() == 1;
updateAccounts(
mIdpForDisplay,
accounts,
areAccountsClickable,
supportsAddAccount && !isSingleAccountChooser);
// If there is a change in the header, setFocusView() will be called and focus will land on
// the header when screen reader is on. Since the header is updated before any item is
// created, the header will always take precedence for focus. Do not reorder this
// updateHeader() call to happen after item creation.
updateHeader();
boolean isDataSharingConsentVisible = false;
Callback<Account> continueButtonCallback = null;
if (mHeaderType == HeaderType.SIGN_IN && mSelectedAccount != null) {
// Only show the user data sharing consent text for sign up and only
// if we're asked to request permission.
isDataSharingConsentVisible = !mSelectedAccount.isSignIn() && mRequestPermission;
continueButtonCallback = this::onClickAccountSelected;
}
if (mHeaderType == HeaderType.VERIFY_AUTO_REAUTHN) {
assert mSelectedAccount != null;
assert mSelectedAccount.isSignIn();
onAccountSelected(mSelectedAccount);
}
if (mHeaderType == HeaderType.SIGN_IN_TO_IDP_STATIC) {
assert !isDataSharingConsentVisible;
assert mSelectedAccount == null;
continueButtonCallback = this::onLoginToIdP;
}
if (supportsAddAccount && isSingleAccountChooser) {
assert !isDataSharingConsentVisible;
assert mSelectedAccount == null;
mSelectedAccount = accounts.get(0);
continueButtonCallback = this::onClickAccountSelected;
}
if (mHeaderType == HeaderType.SIGN_IN_ERROR) {
assert !isDataSharingConsentVisible;
continueButtonCallback = this::onClickGotItButton;
}
if (mHeaderType == HeaderType.REQUEST_PERMISSION) {
assert mSelectedAccount != null;
isDataSharingConsentVisible = true;
continueButtonCallback = this::onClickAccountSelected;
}
// On button mode since the disclosure text is above the continue button, create the
// disclosure text before creating the continue button so setFocusView() will focus
// in logical linear reading order. Keep the order in mind when adding an item that calls
// setFocusView() because the first item which calls it will get the focus.
if (mRpMode == RpMode.BUTTON) {
mModel.set(
ItemProperties.DATA_SHARING_CONSENT,
isDataSharingConsentVisible
? createDataSharingConsentItem(mIdpForDisplay, mClientMetadata)
: null);
}
mModel.set(
ItemProperties.CONTINUE_BUTTON,
(continueButtonCallback != null)
? createContinueBtnItem(
mSelectedAccount, mIdpMetadata, continueButtonCallback)
: null);
// On widget mode since the disclosure text is below the continue button, create the
// disclosure text after creating the continue button so setFocusView() will focus
// in logical linear reading order. Keep the order in mind when adding an item that calls
// setFocusView() because the first item which calls it will get the focus.
if (mRpMode == RpMode.WIDGET) {
mModel.set(
ItemProperties.DATA_SHARING_CONSENT,
isDataSharingConsentVisible
? createDataSharingConsentItem(mIdpForDisplay, mClientMetadata)
: null);
}
mModel.set(
ItemProperties.IDP_SIGNIN,
mHeaderType == HeaderType.SIGN_IN_TO_IDP_STATIC
? createIdpSignInItem(mIdpForDisplay)
: null);
mModel.set(
ItemProperties.ERROR_TEXT,
mHeaderType == HeaderType.SIGN_IN_ERROR
? createErrorTextItem(mIdpForDisplay, mRpForDisplay, mError)
: null);
// For multiple account choosers, the add account button is added as an account row.
mModel.set(
ItemProperties.ADD_ACCOUNT_BUTTON,
supportsAddAccount && isSingleAccountChooser ? createAddAccountBtnItem() : null);
mModel.set(
ItemProperties.ACCOUNT_CHIP,
mHeaderType == HeaderType.REQUEST_PERMISSION
? createAccountItem(mSelectedAccount, /* isAccountClickable= */ false)
: null);
mModel.set(
ItemProperties.SPINNER_ENABLED,
mRpMode == RpMode.BUTTON
&& (mHeaderType == HeaderType.LOADING
|| mHeaderType == HeaderType.VERIFY
|| mHeaderType == HeaderType.VERIFY_AUTO_REAUTHN));
mBottomSheetController.expandSheet();
// When a user opens a page that invokes the FedCM API in a new tab, the tab will be hidden
// and we should not show the bottom sheet to avoid confusion.
mTab.addObserver(mTabObserver);
if (!mTab.isHidden()) showContent();
}
private void updateHeader() {
PropertyModel headerModel =
createHeaderItem(mHeaderType, mRpForDisplay, mIdpForDisplay, mRpContext);
mModel.set(ItemProperties.HEADER, headerModel);
}
/**
* Requests to show the bottom sheet. If it is not possible to immediately show the content
* (e.g., higher priority content is being shown) it removes the request from the bottom sheet
* controller queue and notifies the delegate of the dismissal.
*/
private void showContent() {
if (mWasDismissed) return;
if (mIsModalDialogOpen) return;
// When button mode is triggered, if there's a pending widget mode request, we should
// prioritize the button mode since it's gated by user intention. With the UI code, both
// button flow bottom sheet and widget flow bottom sheet have the same predefined priority
// therefore the consecutive button flow would be dismissed. Here we override the
// calculation and prioritize the button flow request.
boolean prioritizeButtonMode =
mRpMode == RpMode.BUTTON && mHeaderType == HeaderType.LOADING;
if (mBottomSheetController.requestShowContent(mBottomSheetContent, true)
|| prioritizeButtonMode) {
if (mRegisteredObservers) return;
mRegisteredObservers = true;
if (mHeaderType == HeaderType.SIGN_IN
|| mHeaderType == HeaderType.VERIFY
|| mHeaderType == HeaderType.VERIFY_AUTO_REAUTHN) {
mDelegate.onAccountsDisplayed();
}
mBottomSheetController.addObserver(mBottomSheetObserver);
KeyboardVisibilityDelegate.getInstance()
.addKeyboardVisibilityListener(mKeyboardVisibilityListener);
if (!mTab.hasObserver(mTabObserver)) mTab.addObserver(mTabObserver);
} else {
onDismissed(IdentityRequestDialogDismissReason.OTHER);
}
}
/** Requests to dismiss bottomsheet. */
void dismissContent() {
mWasDismissed = true;
KeyboardVisibilityDelegate.getInstance()
.removeKeyboardVisibilityListener(mKeyboardVisibilityListener);
mTab.removeObserver(mTabObserver);
mBottomSheetController.hideContent(mBottomSheetContent, true);
mBottomSheetController.removeObserver(mBottomSheetObserver);
updateBackPressBehavior();
}
private void requestAvatarImage(PropertyModel accountModel) {
Account account = accountModel.get(AccountProperties.ACCOUNT);
final String name = account.getName();
final Bitmap picture = account.getPictureBitmap();
accountModel.set(
AccountProperties.AVATAR,
new AccountProperties.Avatar(name, picture, mDesiredAvatarSize));
}
boolean wasDismissed() {
return mWasDismissed;
}
/**
* Event listener for when the user taps on an account or the continue button of the
* bottomsheet, when it is an IdP sign-in sheet.
*/
void onLoginToIdP(Account account) {
// This method only has an Account to match the type of the event listener.
assert account == null;
if (!shouldInputBeProcessed()) return;
maybeRecordAccountChooserResult(AccountChooserResult.USE_OTHER_ACCOUNT_BUTTON);
mDelegate.onLoginToIdP(mIdpMetadata.getConfigUrl(), mIdpMetadata.getLoginUrl());
}
/** Event listener for when the user taps on the more details button of the bottomsheet. */
void onMoreDetails() {
if (!shouldInputBeProcessed()) return;
mDelegate.onMoreDetails();
onDismissed(IdentityRequestDialogDismissReason.MORE_DETAILS_BUTTON);
}
/**
* Event listener for when the user taps on an account or the continue button of the
* bottomsheet.
*
* @param selectedAccount is the account that the user tapped on. If the user instead tapped on
* the continue button, it is the account displayed if this was the single account chooser.
*/
void onClickAccountSelected(Account selectedAccount) {
if (!shouldInputBeProcessed()) return;
maybeRecordAccountChooserResult(AccountChooserResult.ACCOUNT_ROW);
onAccountSelected(selectedAccount);
}
/** Event listener for when the user taps on the got it button of the bottomsheet. */
void onClickGotItButton(Account account) {
// This method only has an Account to match the type of the event listener. However, it
// should be non-null because an account must have been selected in order to reach an error
// dialog.
assert account != null;
if (!shouldInputBeProcessed()) return;
onDismissed(IdentityRequestDialogDismissReason.GOT_IT_BUTTON);
}
void onAccountSelected(Account selectedAccount) {
if (mWasDismissed) return;
// There is an old selected account if an account was already selected from an account
// chooser and it implies that this `onAccountSelected` call comes from a dialog containing
// disclosure text or that the user has just signed into a new account on an IDP through
// FedCM.
Account oldSelectedAccount = mSelectedAccount;
mSelectedAccount = selectedAccount;
// If the account is a returning user or if the account is selected from UI which shows the
// disclosure text or if the browser doesn't need to request permission because the IDP
// prefers asking for permission by themselves, skip the disclosure UI and proceed to the
// verifying sheet.
if ((mRpMode == RpMode.WIDGET && oldSelectedAccount != null)
|| selectedAccount.isSignIn()
|| mHeaderType == HeaderType.REQUEST_PERMISSION
|| !mRequestPermission) {
mDelegate.onAccountSelected(mIdpMetadata.getConfigUrl(), selectedAccount);
showVerifySheet(selectedAccount);
return;
}
// At this point, the account is a non-returning user. If RP mode is button,
// we'd request permission through the request permission dialog.
if (mRpMode == RpMode.BUTTON) {
showRequestPermissionSheet(selectedAccount);
return;
}
// At this point, the account is a non-returning user and RP mode is widget.
showAccountsInternal(/* newAccountsIdp= */ null);
}
void onDismissed(@IdentityRequestDialogDismissReason int dismissReason) {
if (mAccountChooserState != null) {
maybeRecordAccountChooserResult(mAccountChooserState);
}
dismissContent();
mDelegate.onDismissed(dismissReason);
}
private PropertyModel createAccountItem(Account account, boolean isAccountClickable) {
PropertyModel model =
new PropertyModel.Builder(AccountProperties.ALL_KEYS)
.with(AccountProperties.ACCOUNT, account)
.with(
AccountProperties.ON_CLICK_LISTENER,
isAccountClickable ? this::onClickAccountSelected : null)
.build();
requestAvatarImage(model);
return model;
}
private PropertyModel createContinueBtnItem(
Account account,
IdentityProviderMetadata idpMetadata,
Callback<Account> onClickListener) {
assert account != null
|| mHeaderType == HeaderProperties.HeaderType.SIGN_IN_TO_IDP_STATIC
|| mHeaderType == HeaderProperties.HeaderType.SIGN_IN_ERROR
|| mHeaderType == HeaderProperties.HeaderType.SIGN_IN;
ContinueButtonProperties.Properties properties = new ContinueButtonProperties.Properties();
properties.mAccount = account;
properties.mIdpMetadata = idpMetadata;
properties.mOnClickListener = onClickListener;
properties.mHeaderType = mHeaderType;
properties.mSetFocusViewCallback = this::setFocusView;
return new PropertyModel.Builder(ContinueButtonProperties.ALL_KEYS)
.with(ContinueButtonProperties.PROPERTIES, properties)
.build();
}
private PropertyModel createAddAccountBtnItem() {
AddAccountButtonProperties.Properties properties =
new AddAccountButtonProperties.Properties();
properties.mIdpMetadata = mIdpMetadata;
properties.mOnClickListener = this::onLoginToIdP;
return new PropertyModel.Builder(AddAccountButtonProperties.ALL_KEYS)
.with(AddAccountButtonProperties.PROPERTIES, properties)
.build();
}
private PropertyModel createDataSharingConsentItem(
String idpForDisplay, ClientIdMetadata metadata) {
DataSharingConsentProperties.Properties properties =
new DataSharingConsentProperties.Properties();
properties.mIdpForDisplay = idpForDisplay;
properties.mTermsOfServiceUrl = metadata.getTermsOfServiceUrl();
properties.mPrivacyPolicyUrl = metadata.getPrivacyPolicyUrl();
properties.mTermsOfServiceClickCallback =
(Context context) -> {
showUrl(
context,
IdentityRequestDialogLinkType.TERMS_OF_SERVICE,
metadata.getTermsOfServiceUrl());
};
properties.mPrivacyPolicyClickCallback =
(Context context) -> {
showUrl(
context,
IdentityRequestDialogLinkType.PRIVACY_POLICY,
metadata.getPrivacyPolicyUrl());
};
properties.mSetFocusViewCallback = this::setFocusView;
return new PropertyModel.Builder(DataSharingConsentProperties.ALL_KEYS)
.with(DataSharingConsentProperties.PROPERTIES, properties)
.build();
}
private PropertyModel createIdpSignInItem(String idpForDisplay) {
return new PropertyModel.Builder(IdpSignInProperties.ALL_KEYS)
.with(IdpSignInProperties.IDP_FOR_DISPLAY, idpForDisplay)
.build();
}
private PropertyModel createErrorTextItem(
String idpForDisplay, String rpForDisplay, IdentityCredentialTokenError error) {
ErrorProperties.Properties properties = new ErrorProperties.Properties();
properties.mIdpForDisplay = idpForDisplay;
properties.mRpForDisplay = rpForDisplay;
properties.mError = error;
properties.mMoreDetailsClickRunnable =
!error.getUrl().isEmpty() ? this::onMoreDetails : null;
return new PropertyModel.Builder(ErrorProperties.ALL_KEYS)
.with(ErrorProperties.PROPERTIES, properties)
.build();
}
void onModalDialogOpened() {
mIsModalDialogOpen = true;
}
void onModalDialogClosed() {
mIsModalDialogOpen = false;
}
}