chromium/chrome/browser/password_check/android/internal/java/src/org/chromium/chrome/browser/password_check/PasswordCheckViewBinder.java

// Copyright 2020 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_check;

import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.CompromisedCredentialProperties.COMPROMISED_CREDENTIAL;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.CompromisedCredentialProperties.CREDENTIAL_HANDLER;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.CompromisedCredentialProperties.FAVICON_OR_FALLBACK;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.CompromisedCredentialProperties.HAS_MANUAL_CHANGE_BUTTON;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.DELETION_CONFIRMATION_HANDLER;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.DELETION_ORIGIN;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.HeaderProperties.CHECK_PROGRESS;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.HeaderProperties.CHECK_STATUS;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.HeaderProperties.CHECK_TIMESTAMP;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.HeaderProperties.COMPROMISED_CREDENTIALS_COUNT;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.HeaderProperties.LAUNCH_ACCOUNT_CHECKUP_ACTION;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.HeaderProperties.RESTART_BUTTON_ACTION;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.HeaderProperties.SHOW_CHECK_SUBTITLE;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.HeaderProperties.UNKNOWN_PROGRESS;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.ITEMS;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.VIEW_CREDENTIAL;
import static org.chromium.chrome.browser.password_check.PasswordCheckProperties.VIEW_DIALOG_HANDLER;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.text.method.LinkMovementMethod;
import android.util.Pair;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;

import androidx.annotation.ColorRes;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.drawable.DrawableCompat;

import org.chromium.chrome.browser.password_check.PasswordCheckProperties.ItemType;
import org.chromium.chrome.browser.password_check.helper.PasswordCheckIconHelper;
import org.chromium.chrome.browser.ui.favicon.FaviconUtils;
import org.chromium.components.browser_ui.widget.BrowserUiListMenuUtils;
import org.chromium.ui.listmenu.ListMenu;
import org.chromium.ui.listmenu.ListMenuButton;
import org.chromium.ui.listmenu.ListMenuItemProperties;
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;
import org.chromium.ui.text.NoUnderlineClickableSpan;
import org.chromium.ui.text.SpanApplier;
import org.chromium.ui.widget.ButtonCompat;

/**
 * Provides functions that map {@link PasswordCheckProperties} changes in a {@link PropertyModel} to
 * the suitable method in {@link PasswordCheckFragmentView}.
 */
class PasswordCheckViewBinder {
    /**
     * Called whenever a property in the given model changes. It updates the given view
     * accordingly.
     *
     * @param model       The observed {@link PropertyModel}. Its data is reflected in the view.
     * @param view        The {@link PasswordCheckFragmentView} to update.
     * @param propertyKey The {@link PropertyKey} which changed.
     */
    static void bindPasswordCheckView(
            PropertyModel model, PasswordCheckFragmentView view, PropertyKey propertyKey) {
        if (propertyKey == ITEMS) {
            view.getListView()
                    .setAdapter(
                            new RecyclerViewAdapter<>(
                                    new SimpleRecyclerViewMcp<>(
                                            model.get(ITEMS),
                                            PasswordCheckProperties::getItemType,
                                            PasswordCheckViewBinder::connectPropertyModel),
                                    PasswordCheckViewBinder::createViewHolder));
        } else if (propertyKey == DELETION_CONFIRMATION_HANDLER) {
            if (model.get(DELETION_CONFIRMATION_HANDLER) == null) return; // Initial or onDismiss.
            view.showDialogFragment(
                    new PasswordCheckDeletionDialogFragment(
                            model.get(DELETION_CONFIRMATION_HANDLER), model.get(DELETION_ORIGIN)));
        } else if (propertyKey == DELETION_ORIGIN) {
            // Binding not necessary (only used indirectly).
        } else if (propertyKey == VIEW_CREDENTIAL) {
            // Binding not necessary (only used indirectly).
        } else if (propertyKey == VIEW_DIALOG_HANDLER) {
            if (model.get(VIEW_DIALOG_HANDLER) == null) return; // Initial or onDismiss.
            view.showDialogFragment(
                    new PasswordCheckViewDialogFragment(
                            model.get(VIEW_DIALOG_HANDLER), model.get(VIEW_CREDENTIAL)));
        } else {
            assert false : "Unhandled update to property:" + propertyKey;
        }
    }

    /**
     * Factory used to create a new View inside the list inside the PasswordCheckFragmentView.
     *
     * @param parent   The parent {@link ViewGroup} of the new item.
     * @param itemType The type of View to create.
     */
    private static PasswordCheckViewHolder createViewHolder(
            ViewGroup parent, @ItemType int itemType) {
        switch (itemType) {
            case ItemType.HEADER:
                return new PasswordCheckViewHolder(
                        parent,
                        R.layout.password_check_header_item,
                        PasswordCheckViewBinder::bindHeaderView);
            case ItemType.COMPROMISED_CREDENTIAL:
                return new PasswordCheckViewHolder(
                        parent,
                        R.layout.password_check_compromised_credential_item,
                        PasswordCheckViewBinder::bindCredentialView);
        }
        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 PasswordCheckViewHolder} holding a view and view binder for the MCP.
     * @param item   A {@link MVCListAdapter.ListItem} holding a {@link PropertyModel} for the MCP.
     */
    private static void connectPropertyModel(
            PasswordCheckViewHolder 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 a recycled view with old data, so make sure to always reset unused properties to
     * default values.
     *
     * @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) {
        CompromisedCredential credential = model.get(COMPROMISED_CREDENTIAL);
        if (propertyKey == COMPROMISED_CREDENTIAL) {
            TextView originText = view.findViewById(R.id.credential_origin);
            originText.setText(credential.getDisplayOrigin());

            TextView username = view.findViewById(R.id.compromised_username);
            username.setText(credential.getDisplayUsername());

            TextView reason = view.findViewById(R.id.compromised_reason);
            reason.setText(getReasonForCredential(credential));

            ListMenuButton more = view.findViewById(R.id.credential_menu_button);
            more.setDelegate(
                    () -> {
                        return createCredentialMenu(
                                view.getContext(),
                                model.get(COMPROMISED_CREDENTIAL),
                                model.get(CREDENTIAL_HANDLER));
                    });

            ButtonCompat button = view.findViewById(R.id.credential_change_button);
            button.setOnClickListener(
                    unusedView -> {
                        model.get(CREDENTIAL_HANDLER).onChangePasswordButtonClick(credential);
                    });
            setTintListForCompoundDrawables(
                    button.getCompoundDrawablesRelative(),
                    view.getContext(),
                    R.color.default_text_color_on_accent1_list);
        } else if (propertyKey == CREDENTIAL_HANDLER) {
            assert model.get(CREDENTIAL_HANDLER) != null;
            // Is read-only and must therefore be bound initially, so no action required.
        } else if (propertyKey == HAS_MANUAL_CHANGE_BUTTON) {
            ButtonCompat button = view.findViewById(R.id.credential_change_button);
            button.setVisibility(model.get(HAS_MANUAL_CHANGE_BUTTON) ? View.VISIBLE : View.GONE);
            TextView changeHint = view.findViewById(R.id.credential_change_hint);
            changeHint.setVisibility(
                    model.get(HAS_MANUAL_CHANGE_BUTTON) ? View.GONE : View.VISIBLE);
        } else if (propertyKey == FAVICON_OR_FALLBACK) {
            ImageView imageView = view.findViewById(R.id.credential_favicon);
            PasswordCheckIconHelper.FaviconOrFallback data = model.get(FAVICON_OR_FALLBACK);
            Resources resources = view.getResources();
            Context context = view.getContext();
            imageView.setImageDrawable(
                    FaviconUtils.getIconDrawableWithoutFilter(
                            data.mIcon,
                            data.mUrlOrAppName,
                            PasswordCheckIconHelper.getIconColor(data, context),
                            FaviconUtils.createCircularIconGenerator(context),
                            resources,
                            resources.getDimensionPixelSize(R.dimen.default_favicon_size)));
        } else {
            assert false : "Unhandled update to property:" + propertyKey;
        }
    }

    private static @StringRes int getReasonForCredential(CompromisedCredential credential) {
        if (!credential.isOnlyPhished()) {
            return R.string.password_check_credential_row_reason_leaked;
        }
        if (!credential.isOnlyLeaked()) {
            return R.string.password_check_credential_row_reason_phished;
        }
        return R.string.password_check_credential_row_reason_leaked_and_phished;
    }

    /**
     * Called whenever a property in the given model changes. It updates the given view
     * accordingly.
     *
     * @param model The observed {@link PropertyModel}. Its data needs 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) {
        Pair<Integer, Integer> progress = model.get(CHECK_PROGRESS);
        @PasswordCheckUIStatus int status = model.get(CHECK_STATUS);
        Long checkTimestamp = model.get(CHECK_TIMESTAMP);
        Integer compromisedCredentialsCount = model.get(COMPROMISED_CREDENTIALS_COUNT);
        Runnable launchCheckupInAccount = model.get(LAUNCH_ACCOUNT_CHECKUP_ACTION);
        boolean showStatusSubtitle = model.get(SHOW_CHECK_SUBTITLE);

        if (key == CHECK_PROGRESS) {
            updateStatusText(
                    view,
                    status,
                    compromisedCredentialsCount,
                    checkTimestamp,
                    progress,
                    launchCheckupInAccount);
        } else if (key == CHECK_STATUS) {
            updateActionButton(view, status, model.get(RESTART_BUTTON_ACTION));
            updateStatusIcon(view, status, compromisedCredentialsCount);
            updateStatusIllustration(view, status, compromisedCredentialsCount);
            updateStatusText(
                    view,
                    status,
                    compromisedCredentialsCount,
                    checkTimestamp,
                    progress,
                    launchCheckupInAccount);
            updateStatusSubtitle(view, status, showStatusSubtitle, compromisedCredentialsCount);
        } else if (key == CHECK_TIMESTAMP) {
            updateStatusText(
                    view,
                    status,
                    compromisedCredentialsCount,
                    checkTimestamp,
                    progress,
                    launchCheckupInAccount);
        } else if (key == COMPROMISED_CREDENTIALS_COUNT) {
            updateStatusIcon(view, status, compromisedCredentialsCount);
            updateStatusIllustration(view, status, compromisedCredentialsCount);
            updateStatusText(
                    view,
                    status,
                    compromisedCredentialsCount,
                    checkTimestamp,
                    progress,
                    launchCheckupInAccount);
            updateStatusSubtitle(view, status, showStatusSubtitle, compromisedCredentialsCount);
        } else if (key == LAUNCH_ACCOUNT_CHECKUP_ACTION) {
            assert model.get(LAUNCH_ACCOUNT_CHECKUP_ACTION) != null
                    : "Launch checkup in account is always required.";
        } else if (key == RESTART_BUTTON_ACTION) {
            assert model.get(RESTART_BUTTON_ACTION) != null : "Restart action is always required.";
        } else if (key == SHOW_CHECK_SUBTITLE) {
            updateStatusSubtitle(view, status, showStatusSubtitle, compromisedCredentialsCount);
        } else {
            assert false : "Unhandled update to property:" + key;
        }
    }

    private PasswordCheckViewBinder() {}

    private static void updateActionButton(
            View view, @PasswordCheckUIStatus int status, Runnable startCheck) {
        ImageButton restartButton = view.findViewById(R.id.check_status_restart_button);
        LinearLayout textWrapper = view.findViewById(R.id.check_status_text_layout);
        boolean shouldBeVisible = shouldShowActionButton(status);

        LinearLayout.LayoutParams layoutParams =
                (LinearLayout.LayoutParams) textWrapper.getLayoutParams();
        layoutParams.setMarginEnd(
                shouldBeVisible
                        ? 0
                        : view.getResources()
                                .getDimensionPixelSize(R.dimen.check_status_text_margin));

        restartButton.setVisibility(shouldBeVisible ? View.VISIBLE : View.GONE);
        restartButton.setOnClickListener(shouldBeVisible ? unusedView -> startCheck.run() : null);
        restartButton.setClickable(shouldBeVisible);
    }

    private static void updateStatusIcon(
            View view, @PasswordCheckUIStatus int status, Integer compromisedCredentialsCount) {
        // TODO(crbug.com/40710602): Set default values for header properties.
        if (status == PasswordCheckUIStatus.IDLE && compromisedCredentialsCount == null) return;
        ImageView statusIcon = view.findViewById(R.id.check_status_icon);
        statusIcon.setImageResource(getIconResource(status, compromisedCredentialsCount));
        statusIcon.setVisibility(getIconVisibility(status));
        view.findViewById(R.id.check_status_progress)
                .setVisibility(getProgressBarVisibility(status));
    }

    private static boolean shouldShowActionButton(@PasswordCheckUIStatus int status) {
        switch (status) {
            case PasswordCheckUIStatus.IDLE:
            case PasswordCheckUIStatus.ERROR_OFFLINE:
            case PasswordCheckUIStatus.ERROR_UNKNOWN:
                return true;
            case PasswordCheckUIStatus.RUNNING:
            case PasswordCheckUIStatus.ERROR_NO_PASSWORDS:
            case PasswordCheckUIStatus.ERROR_SIGNED_OUT:
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT:
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT_ACCOUNT_CHECK:
                return false;
        }
        assert false : "Unhandled check status " + status + "on action button update";
        return false;
    }

    private static int getIconResource(
            @PasswordCheckUIStatus int status, Integer compromisedCredentialsCount) {
        switch (status) {
            case PasswordCheckUIStatus.IDLE:
                assert compromisedCredentialsCount != null;
                return compromisedCredentialsCount == 0
                        ? R.drawable.ic_check_circle_filled_green_24dp
                        : R.drawable.ic_warning_red_24dp;
            case PasswordCheckUIStatus.RUNNING:
                return 0;
            case PasswordCheckUIStatus.ERROR_OFFLINE:
            case PasswordCheckUIStatus.ERROR_NO_PASSWORDS:
            case PasswordCheckUIStatus.ERROR_SIGNED_OUT:
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT:
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT_ACCOUNT_CHECK:
            case PasswordCheckUIStatus.ERROR_UNKNOWN:
                return R.drawable.ic_error_grey800_24dp_filled;
            default:
                assert false : "Unhandled check status " + status + "on icon update";
        }
        return 0;
    }

    private static int getIconVisibility(@PasswordCheckUIStatus int status) {
        return status == PasswordCheckUIStatus.RUNNING ? View.GONE : View.VISIBLE;
    }

    private static int getProgressBarVisibility(@PasswordCheckUIStatus int status) {
        return status == PasswordCheckUIStatus.RUNNING ? View.VISIBLE : View.GONE;
    }

    private static void updateStatusText(
            View view,
            @PasswordCheckUIStatus int status,
            Integer compromisedCredentialsCount,
            Long checkTimestamp,
            Pair<Integer, Integer> progress,
            Runnable launchCheckupInAccount) {
        // TODO(crbug.com/40710602): Set default values for header properties.
        if (status == PasswordCheckUIStatus.IDLE
                && (compromisedCredentialsCount == null || checkTimestamp == null)) {
            return;
        }
        if (status == PasswordCheckUIStatus.RUNNING && progress == null) return;

        TextView statusMessage = view.findViewById(R.id.check_status_message);
        statusMessage.setText(
                getStatusMessage(
                        view,
                        status,
                        compromisedCredentialsCount,
                        progress,
                        launchCheckupInAccount));
        statusMessage.setMovementMethod(LinkMovementMethod.getInstance());

        LinearLayout textLayout = view.findViewById(R.id.check_status_text_layout);
        int verticalMargin = getDimensionPixelOffset(view, getStatusTextMargin(status));
        textLayout.setPadding(0, verticalMargin, 0, verticalMargin);

        TextView statusDescription = view.findViewById(R.id.check_status_description);
        statusDescription.setText(getStatusDescription(view, checkTimestamp));
        statusDescription.setVisibility(getStatusDescriptionVisibility(status));
    }

    private static CharSequence getStatusMessage(
            View view,
            @PasswordCheckUIStatus int status,
            Integer compromisedCredentialsCount,
            Pair<Integer, Integer> progress,
            Runnable launchCheckupInAccount) {
        switch (status) {
            case PasswordCheckUIStatus.IDLE:
                assert compromisedCredentialsCount != null;
                return compromisedCredentialsCount == 0
                        ? getString(view, R.string.password_check_status_message_idle_no_leaks)
                        : getResources(view)
                                .getQuantityString(
                                        R.plurals.password_check_status_message_idle_with_leaks,
                                        compromisedCredentialsCount,
                                        compromisedCredentialsCount);
            case PasswordCheckUIStatus.RUNNING:
                assert progress != null;
                if (progress.equals(UNKNOWN_PROGRESS)) {
                    return getString(view, R.string.password_check_status_message_initial_running);
                } else {
                    return String.format(
                            getString(view, R.string.password_check_status_message_running),
                            progress.first,
                            progress.second);
                }
            case PasswordCheckUIStatus.ERROR_OFFLINE:
                return getString(view, R.string.password_check_status_message_error_offline);
            case PasswordCheckUIStatus.ERROR_NO_PASSWORDS:
                return getString(view, R.string.password_check_status_message_error_no_passwords);
            case PasswordCheckUIStatus.ERROR_SIGNED_OUT:
                return getString(view, R.string.password_check_status_message_error_signed_out);
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT:
                return getString(view, R.string.password_check_status_message_error_quota_limit);
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT_ACCOUNT_CHECK:
                NoUnderlineClickableSpan linkSpan =
                        new NoUnderlineClickableSpan(
                                view.getContext(), unusedView -> launchCheckupInAccount.run());
                return SpanApplier.applySpans(
                        getString(
                                view,
                                R.string
                                        .password_check_status_message_error_quota_limit_account_check),
                        new SpanApplier.SpanInfo("<link>", "</link>", linkSpan));
            case PasswordCheckUIStatus.ERROR_UNKNOWN:
                return getString(view, R.string.password_check_status_message_error_unknown);
            default:
                assert false : "Unhandled check status " + status + "on message update";
        }
        return null;
    }

    private static int getStatusTextMargin(@PasswordCheckUIStatus int status) {
        switch (status) {
            case PasswordCheckUIStatus.IDLE:
                return R.dimen.check_status_message_idle_margin_vertical;
            case PasswordCheckUIStatus.RUNNING:
                return R.dimen.check_status_message_running_margin_vertical;
            case PasswordCheckUIStatus.ERROR_OFFLINE:
            case PasswordCheckUIStatus.ERROR_NO_PASSWORDS:
            case PasswordCheckUIStatus.ERROR_SIGNED_OUT:
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT:
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT_ACCOUNT_CHECK:
            case PasswordCheckUIStatus.ERROR_UNKNOWN:
                return R.dimen.check_status_message_error_margin_vertical;
            default:
                assert false : "Unhandled check status " + status + "on text margin update";
        }
        return 0;
    }

    private static String getStatusDescription(View view, Long checkTimestamp) {
        if (checkTimestamp == null) return null;
        Resources res = getResources(view);
        return res.getString(
                R.string.password_check_status_description_idle,
                getTimestamp(res, System.currentTimeMillis() - checkTimestamp));
    }

    @VisibleForTesting
    protected static String getTimestamp(Resources res, long timeDeltaMs) {
        if (timeDeltaMs < 0) timeDeltaMs = 0;

        int daysElapsed = (int) (timeDeltaMs / (24L * 60L * 60L * 1000L));
        int hoursElapsed = (int) (timeDeltaMs / (60L * 60L * 1000L));
        int minutesElapsed = (int) (timeDeltaMs / (60L * 1000L));

        String relativeTime;
        if (daysElapsed > 0L) {
            relativeTime = res.getQuantityString(R.plurals.n_days_ago, daysElapsed, daysElapsed);
        } else if (hoursElapsed > 0L) {
            relativeTime = res.getQuantityString(R.plurals.n_hours_ago, hoursElapsed, hoursElapsed);
        } else if (minutesElapsed > 0L) {
            relativeTime =
                    res.getQuantityString(R.plurals.n_minutes_ago, minutesElapsed, minutesElapsed);
        } else {
            relativeTime = res.getString(R.string.password_check_just_now);
        }
        return relativeTime;
    }

    private static int getStatusDescriptionVisibility(@PasswordCheckUIStatus int status) {
        return status == PasswordCheckUIStatus.IDLE ? View.VISIBLE : View.GONE;
    }

    private static void updateStatusIllustration(
            View view, @PasswordCheckUIStatus int status, Integer compromisedCredentialsCount) {
        // TODO(crbug.com/40710602): Set default values for header properties.
        if (status == PasswordCheckUIStatus.IDLE && compromisedCredentialsCount == null) return;
        ImageView statusIllustration = view.findViewById(R.id.check_status_illustration);
        statusIllustration.setImageResource(
                getIllustrationResource(status, compromisedCredentialsCount));
    }

    private static int getIllustrationResource(
            @PasswordCheckUIStatus int status, Integer compromisedCredentialsCount) {
        switch (status) {
            case PasswordCheckUIStatus.IDLE:
                assert compromisedCredentialsCount != null;
                return compromisedCredentialsCount == 0
                        ? R.drawable.password_check_positive
                        : R.drawable.password_checkup_warning;
            case PasswordCheckUIStatus.RUNNING:
            case PasswordCheckUIStatus.ERROR_OFFLINE:
            case PasswordCheckUIStatus.ERROR_NO_PASSWORDS:
            case PasswordCheckUIStatus.ERROR_SIGNED_OUT:
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT:
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT_ACCOUNT_CHECK:
            case PasswordCheckUIStatus.ERROR_UNKNOWN:
                return R.drawable.password_check_neutral;
            default:
                assert false : "Unhandled check status " + status + "on illustration update";
        }
        return 0;
    }

    private static void updateStatusSubtitle(
            View view,
            @PasswordCheckUIStatus int status,
            boolean showStatusSubtitle,
            Integer compromisedCredentialsCount) {
        // TODO(crbug.com/40710602): Set default values for header properties.
        if (status == PasswordCheckUIStatus.IDLE && compromisedCredentialsCount == null) return;
        TextView statusSubtitle = view.findViewById(R.id.check_status_subtitle);
        statusSubtitle.setText(
                getSubtitleText(view, status, showStatusSubtitle, compromisedCredentialsCount));
        statusSubtitle.setVisibility(showStatusSubtitle ? View.VISIBLE : View.GONE);
    }

    private static String getSubtitleText(
            View view,
            @PasswordCheckUIStatus int status,
            boolean showStatusSubtitle,
            Integer compromisedCredentialsCount) {
        switch (status) {
            case PasswordCheckUIStatus.IDLE:
                assert compromisedCredentialsCount != null;
                return compromisedCredentialsCount == 0
                        ? getString(view, R.string.password_check_status_subtitle_no_findings)
                        : getString(
                                view,
                                R.string
                                        .password_check_status_subtitle_found_compromised_credentials);
            case PasswordCheckUIStatus.RUNNING:
            case PasswordCheckUIStatus.ERROR_OFFLINE:
            case PasswordCheckUIStatus.ERROR_NO_PASSWORDS:
            case PasswordCheckUIStatus.ERROR_SIGNED_OUT:
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT:
            case PasswordCheckUIStatus.ERROR_QUOTA_LIMIT_ACCOUNT_CHECK:
            case PasswordCheckUIStatus.ERROR_UNKNOWN:
                return getString(
                        view,
                        R.string.password_check_status_subtitle_found_compromised_credentials);
            default:
                assert false : "Unhandled check status " + status + "on icon update";
        }
        return null;
    }

    private static ListMenu createCredentialMenu(
            Context context,
            CompromisedCredential credential,
            PasswordCheckCoordinator.CredentialEventHandler credentialHandler) {
        MVCListAdapter.ModelList menuItems = new MVCListAdapter.ModelList();
        menuItems.add(
                BrowserUiListMenuUtils.buildMenuListItem(
                        R.string.password_check_credential_menu_item_view_button_caption,
                        0,
                        0,
                        true));
        menuItems.add(
                BrowserUiListMenuUtils.buildMenuListItem(
                        R.string.password_check_credential_menu_item_edit_button_caption,
                        0,
                        0,
                        true));
        menuItems.add(
                BrowserUiListMenuUtils.buildMenuListItem(
                        R.string.password_check_credential_menu_item_remove_button_caption,
                        0,
                        0,
                        true));
        ListMenu.Delegate delegate =
                (listModel) -> {
                    int textId = listModel.get(ListMenuItemProperties.TITLE_ID);
                    if (textId
                            == R.string.password_check_credential_menu_item_view_button_caption) {
                        credentialHandler.onView(credential);
                    } else if (textId
                            == R.string.password_check_credential_menu_item_edit_button_caption) {
                        credentialHandler.onEdit(credential, context);
                    } else if (textId
                            == R.string.password_check_credential_menu_item_remove_button_caption) {
                        credentialHandler.onRemove(credential);
                    } else {
                        assert false : "No action defined for " + context.getString(textId);
                    }
                };
        return BrowserUiListMenuUtils.getBasicListMenu(context, menuItems, delegate);
    }

    private static String getString(View view, int resourceId) {
        return getResources(view).getString(resourceId);
    }

    private static int getDimensionPixelOffset(View view, int resourceId) {
        return getResources(view).getDimensionPixelOffset(resourceId);
    }

    private static Resources getResources(View view) {
        return view.getContext().getResources();
    }

    private static void setTintListForCompoundDrawables(
            Drawable[] compoundDrawables, Context context, @ColorRes int tintColorList) {
        for (Drawable drawable : compoundDrawables) {
            if (drawable == null) continue;
            DrawableCompat.setTintList(
                    drawable, AppCompatResources.getColorStateList(context, tintColorList));
        }
    }
}