chromium/chrome/browser/touch_to_fill/password_manager/android/internal/java/src/org/chromium/chrome/browser/touch_to_fill/TouchToFillViewBinder.java

// Copyright 2019 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.touch_to_fill;

import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.CredentialProperties.CREDENTIAL;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.CredentialProperties.FAVICON_OR_FALLBACK;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.CredentialProperties.FORMATTED_ORIGIN;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.CredentialProperties.ITEM_COLLECTION_INFO;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.CredentialProperties.ON_CLICK_LISTENER;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.CredentialProperties.SHOW_SUBMIT_BUTTON;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.DISMISS_HANDLER;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.FooterProperties.MANAGE_BUTTON_TEXT;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.FooterProperties.ON_CLICK_HYBRID;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.FooterProperties.ON_CLICK_MANAGE;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.FooterProperties.SHOW_HYBRID;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.HeaderProperties.AVATAR;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.HeaderProperties.IMAGE_DRAWABLE_ID;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.HeaderProperties.SUBTITLE;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.HeaderProperties.TITLE;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.SHEET_ITEMS;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.VISIBLE;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.WebAuthnCredentialProperties.ON_WEBAUTHN_CLICK_LISTENER;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.WebAuthnCredentialProperties.SHOW_WEBAUTHN_SUBMIT_BUTTON;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.WebAuthnCredentialProperties.WEBAUTHN_CREDENTIAL;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.WebAuthnCredentialProperties.WEBAUTHN_FAVICON_OR_FALLBACK;
import static org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.WebAuthnCredentialProperties.WEBAUTHN_ITEM_COLLECTION_INFO;

import android.text.Html;
import android.text.method.PasswordTransformationMethod;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;

import androidx.appcompat.content.res.AppCompatResources;

import org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.FaviconOrFallback;
import org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.ItemType;
import org.chromium.chrome.browser.touch_to_fill.TouchToFillProperties.MorePasskeysProperties;
import org.chromium.chrome.browser.touch_to_fill.common.FillableItemCollectionInfo;
import org.chromium.chrome.browser.touch_to_fill.data.Credential;
import org.chromium.chrome.browser.touch_to_fill.data.WebauthnCredential;
import org.chromium.chrome.browser.ui.favicon.FaviconUtils;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.ui.modelutil.MVCListAdapter;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.RecyclerViewAdapter;
import org.chromium.ui.modelutil.SimpleRecyclerViewMcp;

/**
 * Provides functions that map {@link TouchToFillProperties} changes in a {@link PropertyModel} to
 * the suitable method in {@link TouchToFillView}.
 */
class TouchToFillViewBinder {
    /**
     * Called whenever a property in the given model changes. It updates the given view accordingly.
     *
     * @param model The observed {@link PropertyModel}. Its data need to be reflected in the view.
     * @param view The {@link TouchToFillView} to update.
     * @param propertyKey The {@link PropertyKey} which changed.
     */
    static void bindTouchToFillView(
            PropertyModel model, TouchToFillView view, PropertyKey propertyKey) {
        if (propertyKey == DISMISS_HANDLER) {
            view.setDismissHandler(model.get(DISMISS_HANDLER));
        } else if (propertyKey == VISIBLE) {
            boolean visibilityChangeSuccessful = view.setVisible(model.get(VISIBLE));
            if (!visibilityChangeSuccessful && model.get(VISIBLE)) {
                assert (model.get(DISMISS_HANDLER) != null);
                model.get(DISMISS_HANDLER).onResult(BottomSheetController.StateChangeReason.NONE);
            }
        } else if (propertyKey == SHEET_ITEMS) {
            view.setSheetItemListAdapter(
                    new RecyclerViewAdapter<>(
                            new SimpleRecyclerViewMcp<>(
                                    model.get(SHEET_ITEMS),
                                    TouchToFillProperties::getItemType,
                                    TouchToFillViewBinder::connectPropertyModel),
                            TouchToFillViewBinder::createViewHolder));
        } else {
            assert false : "Unhandled update to property:" + propertyKey;
        }
    }

    /**
     * Factory used to create a new View inside the ListView inside the TouchToFillView.
     *
     * @param parent The parent {@link ViewGroup} of the new item.
     * @param itemType The type of View to create.
     */
    private static TouchToFillViewHolder createViewHolder(
            ViewGroup parent, @ItemType int itemType) {
        switch (itemType) {
            case ItemType.HEADER:
                return new TouchToFillViewHolder(
                        parent,
                        R.layout.touch_to_fill_header_item,
                        TouchToFillViewBinder::bindHeaderView);
            case ItemType.CREDENTIAL:
                return new TouchToFillViewHolder(
                        parent,
                        R.layout.touch_to_fill_list_item,
                        TouchToFillViewBinder::bindCredentialView);
            case ItemType.WEBAUTHN_CREDENTIAL:
                return new TouchToFillViewHolder(
                        parent,
                        R.layout.touch_to_fill_list_item,
                        TouchToFillViewBinder::bindWebAuthnCredentialView);
            case ItemType.MORE_PASSKEYS:
                return new TouchToFillViewHolder(
                        parent,
                        R.layout.touch_to_fill_more_passkeys_item,
                        TouchToFillViewBinder::bindMorePasskeysView);
            case ItemType.FILL_BUTTON:
                return new TouchToFillViewHolder(
                        parent,
                        R.layout.touch_to_fill_fill_button,
                        TouchToFillViewBinder::bindFillButtonView);
            case ItemType.FOOTER:
                return new TouchToFillViewHolder(
                        parent,
                        R.layout.touch_to_fill_footer_item,
                        TouchToFillViewBinder::bindFooterView);
        }
        assert false : "Cannot create view for ItemType: " + itemType;
        return null;
    }

    /**
     * This method creates a model change processor for each recycler view item when it is created.
     *
     * @param holder A {@link TouchToFillViewHolder} holding the view and view binder for the MCP.
     * @param item A {@link MVCListAdapter.ListItem} holding the {@link PropertyModel} for the MCP.
     */
    private static void connectPropertyModel(
            TouchToFillViewHolder holder, MVCListAdapter.ListItem item) {
        holder.setupModelChangeProcessor(item.model);
    }

    /**
     * Called whenever a credential is bound to this view holder. Please note that this method might
     * be called on the same list entry repeatedly, so make sure to always set a default for unused
     * fields.
     *
     * @param model The model containing the data for the view
     * @param view The view to be bound
     * @param propertyKey The key of the property to be bound
     */
    private static void bindCredentialView(
            PropertyModel model, View view, PropertyKey propertyKey) {
        Credential credential = model.get(CREDENTIAL);
        if (propertyKey == FAVICON_OR_FALLBACK) {
            ImageView imageView = view.findViewById(R.id.favicon);
            FaviconOrFallback data = model.get(FAVICON_OR_FALLBACK);
            imageView.setImageDrawable(
                    FaviconUtils.getIconDrawableWithoutFilter(
                            data.mIcon,
                            data.mUrl,
                            data.mFallbackColor,
                            FaviconUtils.createCircularIconGenerator(view.getContext()),
                            view.getResources(),
                            data.mIconSize));
        } else if (propertyKey == ON_CLICK_LISTENER) {
            view.setOnClickListener(
                    clickedView -> model.get(ON_CLICK_LISTENER).onResult(credential));
        } else if (propertyKey == FORMATTED_ORIGIN) {
            TextView pslOriginText = view.findViewById(R.id.credential_origin);
            pslOriginText.setText(model.get(FORMATTED_ORIGIN));
            pslOriginText.setVisibility(credential.isExactMatch() ? View.GONE : View.VISIBLE);
        } else if (propertyKey == CREDENTIAL || propertyKey == ITEM_COLLECTION_INFO) {
            TextView pslOriginText = view.findViewById(R.id.credential_origin);
            pslOriginText.setText(credential.getDisplayName());
            pslOriginText.setVisibility(credential.isExactMatch() ? View.GONE : View.VISIBLE);

            TextView usernameText = view.findViewById(R.id.username);
            usernameText.setText(credential.getFormattedUsername());

            TextView passwordText = view.findViewById(R.id.password_or_context);
            passwordText.setText(credential.getPassword());
            passwordText.setTransformationMethod(new PasswordTransformationMethod());

            String label =
                    view.getContext()
                            .getString(
                                    R.string
                                            .touch_to_fill_password_credential_accessibility_description,
                                    credential.getFormattedUsername());
            FillableItemCollectionInfo collectionInfo = model.get(ITEM_COLLECTION_INFO);
            String contentDescription =
                    collectionInfo == null
                            ? label
                            : view.getContext()
                                    .getString(
                                            R.string.touch_to_fill_a11y_item_collection_info,
                                            label,
                                            collectionInfo.getPosition(),
                                            collectionInfo.getTotal());
            view.setContentDescription(contentDescription);
        } else if (propertyKey == SHOW_SUBMIT_BUTTON) {
            // Whether Touch To Fill should auto-submit a form doesn't affect the credentials list.
        } else {
            assert false : "Unhandled update to property:" + propertyKey;
        }
    }

    /**
     * Called whenever a WebAuthn credential is bound to this view holder.
     *
     * @param model The model containing the data for the view
     * @param view The view to be bound
     * @param propertyKey The key of the property to be bound
     */
    private static void bindWebAuthnCredentialView(
            PropertyModel model, View view, PropertyKey propertyKey) {
        WebauthnCredential credential = model.get(WEBAUTHN_CREDENTIAL);
        view.findViewById(R.id.credential_origin).setVisibility(View.GONE);
        if (propertyKey == ON_WEBAUTHN_CLICK_LISTENER) {
            view.setOnClickListener(
                    clickedView -> model.get(ON_WEBAUTHN_CLICK_LISTENER).onResult(credential));
        } else if (propertyKey == WEBAUTHN_FAVICON_OR_FALLBACK) {
            ImageView imageView = view.findViewById(R.id.favicon);
            FaviconOrFallback data = model.get(WEBAUTHN_FAVICON_OR_FALLBACK);
            imageView.setImageDrawable(
                    FaviconUtils.getIconDrawableWithoutFilter(
                            data.mIcon,
                            data.mUrl,
                            data.mFallbackColor,
                            FaviconUtils.createCircularIconGenerator(view.getContext()),
                            view.getResources(),
                            data.mIconSize));
        } else if (propertyKey == WEBAUTHN_CREDENTIAL
                || propertyKey == WEBAUTHN_ITEM_COLLECTION_INFO) {
            TextView usernameText = view.findViewById(R.id.username);
            usernameText.setText(credential.getUsername());
            TextView descriptionText = view.findViewById(R.id.password_or_context);

            descriptionText.setText(R.string.touch_to_fill_sheet_passkey_credential_context);

            String label =
                    view.getContext()
                            .getString(
                                    R.string
                                            .touch_to_fill_passkey_credential_accessibility_description,
                                    credential.getUsername());
            FillableItemCollectionInfo collectionInfo = model.get(WEBAUTHN_ITEM_COLLECTION_INFO);
            String contentDescription =
                    collectionInfo == null
                            ? label
                            : view.getContext()
                                    .getString(
                                            R.string.touch_to_fill_a11y_item_collection_info,
                                            label,
                                            collectionInfo.getPosition(),
                                            collectionInfo.getTotal());
            view.setContentDescription(contentDescription);
        } else if (propertyKey == SHOW_WEBAUTHN_SUBMIT_BUTTON) {
            // Ignore.
        } else {
            assert false : "Unhandled update to property:" + propertyKey;
        }
    }

    /**
     * Called whenever an action button to use more passkeys is bound to this view holder.
     *
     * @param model The model containing the data for the view
     * @param view The view to be bound
     * @param propertyKey The key of the property to be bound
     */
    private static void bindMorePasskeysView(
            PropertyModel model, View view, PropertyKey propertyKey) {
        if (propertyKey == MorePasskeysProperties.ON_CLICK) {
            view.setOnClickListener(
                    clickedView -> model.get(MorePasskeysProperties.ON_CLICK).run());
        } else if (propertyKey == MorePasskeysProperties.TITLE) {
            TextView labelText = view.findViewById(R.id.more_passkeys_label);
            labelText.setText(model.get(MorePasskeysProperties.TITLE));
        } else {
            assert false : "Unhandled update to property: " + propertyKey;
        }
    }

    /**
     * Called whenever a fill button for a single credential is bound to this view holder.
     *
     * @param model The model containing the data for the view
     * @param view The view to be bound
     * @param propertyKey The key of the property to be bound
     */
    private static void bindFillButtonView(
            PropertyModel model, View view, PropertyKey propertyKey) {
        if (propertyKey == ON_CLICK_LISTENER) {
            Credential credential = model.get(CREDENTIAL);
            view.setOnClickListener(
                    clickedView -> {
                        model.get(ON_CLICK_LISTENER).onResult(credential);
                    });
        } else if (propertyKey == ON_WEBAUTHN_CLICK_LISTENER) {
            WebauthnCredential webauthn_credential = model.get(WEBAUTHN_CREDENTIAL);
            view.setOnClickListener(
                    clickedView -> {
                        model.get(ON_WEBAUTHN_CLICK_LISTENER).onResult(webauthn_credential);
                    });
        } else if (propertyKey == SHOW_SUBMIT_BUTTON) {
            TextView buttonTitleText = view.findViewById(R.id.touch_to_fill_button_title);
            buttonTitleText.setText(
                    view.getContext()
                            .getString(
                                    model.get(SHOW_SUBMIT_BUTTON)
                                            ? R.string.touch_to_fill_signin
                                            : R.string.touch_to_fill_continue));
        } else if (propertyKey == SHOW_WEBAUTHN_SUBMIT_BUTTON) {
            TextView buttonTitleText = view.findViewById(R.id.touch_to_fill_button_title);
            buttonTitleText.setText(
                    view.getContext()
                            .getString(
                                    model.get(SHOW_WEBAUTHN_SUBMIT_BUTTON)
                                            ? R.string.touch_to_fill_signin
                                            : R.string.touch_to_fill_continue));
        } else if (propertyKey == FAVICON_OR_FALLBACK
                || propertyKey == FORMATTED_ORIGIN
                || propertyKey == CREDENTIAL
                || propertyKey == WEBAUTHN_CREDENTIAL
                || propertyKey == WEBAUTHN_FAVICON_OR_FALLBACK
                || propertyKey == ITEM_COLLECTION_INFO
                || propertyKey == WEBAUTHN_ITEM_COLLECTION_INFO) {
            // Credential properties don't affect the button.
        } else {
            assert false : "Unhandled update to property:" + propertyKey;
        }
    }

    /**
     * Called whenever a property in the given model changes. It updates the given view accordingly.
     *
     * @param model The observed {@link PropertyModel}. Its data need to be reflected in the view.
     * @param view The {@link View} of the header to update.
     * @param key The {@link PropertyKey} which changed.
     */
    private static void bindHeaderView(PropertyModel model, View view, PropertyKey key) {
        if (key == SUBTITLE || key == TITLE || key == IMAGE_DRAWABLE_ID) {
            TextView sheetTitleText = view.findViewById(R.id.touch_to_fill_sheet_title);
            sheetTitleText.setText(model.get(TITLE));

            TextView sheetSubtitleText = view.findViewById(R.id.touch_to_fill_sheet_subtitle);
            sheetSubtitleText.setText(
                    Html.fromHtml(model.get(SUBTITLE), Html.FROM_HTML_MODE_LEGACY));

            ImageView sheetHeaderImage = view.findViewById(R.id.touch_to_fill_sheet_header_image);
            sheetHeaderImage.setImageDrawable(
                    AppCompatResources.getDrawable(
                            view.getContext(), model.get(IMAGE_DRAWABLE_ID)));
        } else if (key == AVATAR) {
            ImageView sheetHeaderAvatar = view.findViewById(R.id.touch_to_fill_sheet_header_avatar);
            if (model.get(AVATAR) == null) {
                sheetHeaderAvatar.setVisibility(View.INVISIBLE);
            } else {
                sheetHeaderAvatar.setVisibility(View.VISIBLE);
                sheetHeaderAvatar.setImageDrawable(model.get(AVATAR));
            }
        } else {
            assert false : "Unhandled update to property:" + key;
        }
    }

    /**
     * Called whenever a property in the given model changes. It updates the given view accordingly.
     *
     * @param model The observed {@link PropertyModel}. Its data need to be reflected in the view.
     * @param view The {@link View} of the header to update.
     * @param key The {@link PropertyKey} which changed.
     */
    private static void bindFooterView(PropertyModel model, View view, PropertyKey key) {
        if (key == ON_CLICK_MANAGE) {
            view.findViewById(R.id.touch_to_fill_sheet_manage_passwords)
                    .setOnClickListener((v) -> model.get(ON_CLICK_MANAGE).run());
        } else if (key == ON_CLICK_HYBRID) {
            view.findViewById(R.id.touch_to_fill_sheet_use_passkeys_other_device)
                    .setOnClickListener((v) -> model.get(ON_CLICK_HYBRID).run());
        } else if (key == SHOW_HYBRID) {
            view.findViewById(R.id.touch_to_fill_sheet_use_passkeys_other_device)
                    .setVisibility(model.get(SHOW_HYBRID) ? View.VISIBLE : View.GONE);
        } else if (key == MANAGE_BUTTON_TEXT) {
            TextView managePasswordsView =
                    view.findViewById(R.id.touch_to_fill_sheet_manage_passwords);
            managePasswordsView.setText(model.get(MANAGE_BUTTON_TEXT));
        } else {
            assert false : "Unhandled update to property:" + key;
        }
    }

    private TouchToFillViewBinder() {}
}