// Copyright 2015 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.password_manager;
import android.app.Activity;
import android.content.Context;
import android.content.DialogInterface;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import org.jni_zero.CalledByNative;
import org.jni_zero.JniType;
import org.jni_zero.NativeMethods;
import org.chromium.chrome.R;
import org.chromium.components.browser_ui.util.AvatarGenerator;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.widget.Toast;
/**
* A dialog offers the user the ability to choose credentials for authentication. User is
* presented with username along with avatar and full name in case they are available.
* Native counterpart should be notified about credentials user have chosen and also if user
* haven't chosen anything.
*/
public class AccountChooserDialog
implements DialogInterface.OnClickListener, DialogInterface.OnDismissListener {
private final Context mContext;
private final Credential[] mCredentials;
/** Title of the dialog, contains Smart Lock branding for the Smart Lock users. */
private final String mTitle;
private final int mTitleLinkStart;
private final int mTitleLinkEnd;
private final String mOrigin;
private final String mSigninButtonText;
private ArrayAdapter<Credential> mAdapter;
/** Holds the reference to the credentials which were chosen by the user. */
private Credential mCredential;
private long mNativeAccountChooserDialog;
private AlertDialog mDialog;
/**
* True, if credentials were selected via "Sign In" button instead of clicking on the credential
* itself.
*/
private boolean mSigninButtonClicked;
private AccountChooserDialog(
Context context,
long nativeAccountChooserDialog,
Credential[] credentials,
String title,
int titleLinkStart,
int titleLinkEnd,
String origin,
String signinButtonText) {
mNativeAccountChooserDialog = nativeAccountChooserDialog;
mContext = context;
mCredentials = credentials.clone();
mTitle = title;
mTitleLinkStart = titleLinkStart;
mTitleLinkEnd = titleLinkEnd;
mOrigin = origin;
mSigninButtonText = signinButtonText;
mSigninButtonClicked = false;
}
/**
* Creates and shows the dialog which allows user to choose credentials for login.
*
* @param credentials Credentials to display in the dialog.
* @param title Title message for the dialog, which can contain Smart Lock branding.
* @param titleLinkStart Start of a link in case title contains Smart Lock branding.
* @param titleLinkEnd End of a link in case title contains Smart Lock branding.
* @param origin Address of the web page, where dialog was triggered.
*/
@CalledByNative
private static AccountChooserDialog createAndShowAccountChooser(
WindowAndroid windowAndroid,
long nativeAccountChooserDialog,
Credential[] credentials,
@JniType("std::u16string") String title,
int titleLinkStart,
int titleLinkEnd,
@JniType("std::string") String origin,
@JniType("std::u16string") String signinButtonText) {
Activity activity = windowAndroid.getActivity().get();
if (activity == null) return null;
AccountChooserDialog chooser =
new AccountChooserDialog(
activity,
nativeAccountChooserDialog,
credentials,
title,
titleLinkStart,
titleLinkEnd,
origin,
signinButtonText);
chooser.show();
return chooser;
}
private ArrayAdapter<Credential> generateAccountsArrayAdapter(
Context context, Credential[] credentials) {
return new ArrayAdapter<Credential>(context, /* resource= */ 0, credentials) {
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (convertView == null) {
LayoutInflater inflater = LayoutInflater.from(getContext());
convertView =
inflater.inflate(R.layout.account_chooser_dialog_item, parent, false);
}
convertView.setSelected(false);
convertView.setOnClickListener(
view -> {
mCredential = mCredentials[position];
if (mDialog != null) mDialog.dismiss();
});
convertView.setTag(position);
Credential credential = getItem(position);
ImageView avatarView = convertView.findViewById(R.id.profile_image);
Drawable avatar = credential.getAvatar();
if (avatar == null) {
avatar =
AppCompatResources.getDrawable(
getContext(), R.drawable.logo_avatar_anonymous);
}
avatarView.setImageDrawable(avatar);
TextView mainNameView = convertView.findViewById(R.id.main_name);
TextView secondaryNameView = convertView.findViewById(R.id.secondary_name);
if (credential.getFederation().isEmpty()) {
// Not federated credentials case
if (credential.getDisplayName().isEmpty()) {
mainNameView.setText(credential.getUsername());
secondaryNameView.setVisibility(View.GONE);
} else {
mainNameView.setText(credential.getDisplayName());
secondaryNameView.setText(credential.getUsername());
secondaryNameView.setVisibility(View.VISIBLE);
}
} else {
mainNameView.setText(credential.getUsername());
secondaryNameView.setText(credential.getFederation());
secondaryNameView.setVisibility(View.VISIBLE);
}
ImageButton pslInfoButton = convertView.findViewById(R.id.psl_info_btn);
final String originUrl = credential.getOriginUrl();
if (!originUrl.isEmpty()) {
pslInfoButton.setVisibility(View.VISIBLE);
pslInfoButton.setOnClickListener(
new View.OnClickListener() {
@Override
public void onClick(View view) {
showTooltip(
view,
UrlFormatter.formatUrlForSecurityDisplay(originUrl),
R.layout.material_tooltip);
}
});
}
return convertView;
}
};
}
private void show() {
View titleView =
LayoutInflater.from(mContext).inflate(R.layout.account_chooser_dialog_title, null);
TextView origin = titleView.findViewById(R.id.origin);
origin.setText(mOrigin);
TextView titleMessageText = titleView.findViewById(R.id.title);
if (mTitleLinkStart != 0 && mTitleLinkEnd != 0) {
SpannableString spanableTitle = new SpannableString(mTitle);
spanableTitle.setSpan(
new ClickableSpan() {
@Override
public void onClick(View view) {
if (mNativeAccountChooserDialog != 0) {
AccountChooserDialogJni.get()
.onLinkClicked(
mNativeAccountChooserDialog,
AccountChooserDialog.this);
}
mDialog.dismiss();
}
},
mTitleLinkStart,
mTitleLinkEnd,
Spanned.SPAN_INCLUSIVE_INCLUSIVE);
titleMessageText.setText(spanableTitle, TextView.BufferType.SPANNABLE);
titleMessageText.setMovementMethod(LinkMovementMethod.getInstance());
} else {
titleMessageText.setText(mTitle);
}
mAdapter = generateAccountsArrayAdapter(mContext, mCredentials);
final AlertDialog.Builder builder =
new AlertDialog.Builder(mContext, R.style.ThemeOverlay_BrowserUI_AlertDialog)
.setCustomTitle(titleView)
.setNegativeButton(R.string.cancel, this)
.setAdapter(
mAdapter,
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int item) {
mCredential = mCredentials[item];
}
});
if (!TextUtils.isEmpty(mSigninButtonText)) {
builder.setPositiveButton(mSigninButtonText, this);
}
mDialog = builder.create();
mDialog.setOnDismissListener(this);
mDialog.show();
}
// status_bar_height is not a public framework resource, so we have to getIdentifier()
@SuppressWarnings("DiscouragedApi")
private void showTooltip(View view, String message, int layoutId) {
Context context = view.getContext();
Resources resources = context.getResources();
LayoutInflater inflater = LayoutInflater.from(context);
TextView text = (TextView) inflater.inflate(layoutId, null);
text.setText(message);
text.announceForAccessibility(message);
// The tooltip should be shown above and to the left (right for RTL) of the info button.
// In order to do so the tooltip's location on the screen is determined. This location is
// specified with regard to the top left corner and ignores RTL layouts. For this reason the
// location of the tooltip is also specified as offsets to the top left corner of the
// screen. Since the tooltip should be shown above the info button, the height of the
// tooltip needs to be measured. Furthermore, the height of the statusbar is ignored when
// obtaining the icon's screen location, but must be considered when specifying a y offset.
// In addition, the measured width is needed in LTR layout, so that the right end of the
// tooltip aligns with the right end of the info icon.
final int[] screenPos = new int[2];
view.getLocationOnScreen(screenPos);
text.measure(
MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
final int width = view.getWidth();
final int xOffset =
view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
? screenPos[0]
: screenPos[0] + width - text.getMeasuredWidth();
final int statusBarHeightResourceId =
resources.getIdentifier("status_bar_height", "dimen", "android");
final int statusBarHeight =
statusBarHeightResourceId > 0
? resources.getDimensionPixelSize(statusBarHeightResourceId)
: 0;
final int tooltipMargin = resources.getDimensionPixelSize(R.dimen.psl_info_tooltip_margin);
final int yOffset =
screenPos[1] - tooltipMargin - statusBarHeight - text.getMeasuredHeight();
// The xOffset is with regard to the left edge of the screen. Gravity.LEFT is deprecated,
// which is why the following line is necessary.
final int xGravity =
view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL
? Gravity.END
: Gravity.START;
Toast toast = new Toast(context, text);
toast.setGravity(Gravity.TOP | xGravity, xOffset, yOffset);
toast.setDuration(Toast.LENGTH_SHORT);
toast.show();
}
@CalledByNative
private void imageFetchComplete(int index, Bitmap avatarBitmap) {
if (mNativeAccountChooserDialog == 0) return;
assert index >= 0 && index < mCredentials.length;
assert mCredentials[index] != null;
Drawable avatar =
AvatarGenerator.makeRoundAvatar(
mContext.getResources(), avatarBitmap, avatarBitmap.getHeight());
mCredentials[index].setAvatar(avatar);
ListView view = mDialog.getListView();
if (index >= view.getFirstVisiblePosition() && index <= view.getLastVisiblePosition()) {
// Profile image is in the visible range.
View credentialView = view.getChildAt(index - view.getFirstVisiblePosition());
if (credentialView == null) return;
ImageView avatarView = credentialView.findViewById(R.id.profile_image);
avatarView.setImageDrawable(avatar);
}
}
@CalledByNative
private void notifyNativeDestroyed() {
mNativeAccountChooserDialog = 0;
if (mDialog != null) mDialog.dismiss();
}
@Override
public void onClick(DialogInterface dialog, int whichButton) {
if (whichButton == DialogInterface.BUTTON_POSITIVE) {
mCredential = mCredentials[0];
mSigninButtonClicked = true;
}
}
@Override
public void onDismiss(DialogInterface dialog) {
mDialog = null;
if (mNativeAccountChooserDialog == 0) return;
if (mCredential != null) {
AccountChooserDialogJni.get()
.onCredentialClicked(
mNativeAccountChooserDialog,
AccountChooserDialog.this,
mCredential.getIndex(),
mSigninButtonClicked);
} else {
AccountChooserDialogJni.get()
.cancelDialog(mNativeAccountChooserDialog, AccountChooserDialog.this);
}
}
@NativeMethods
interface Natives {
void onCredentialClicked(
long nativeAccountChooserDialogAndroid,
AccountChooserDialog caller,
int credentialId,
boolean signinButtonClicked);
void cancelDialog(long nativeAccountChooserDialogAndroid, AccountChooserDialog caller);
void onLinkClicked(long nativeAccountChooserDialogAndroid, AccountChooserDialog caller);
}
}