chromium/chrome/android/features/keyboard_accessory/internal/java/src/org/chromium/chrome/browser/keyboard_accessory/bar_component/KeyboardAccessoryMediator.java

// Copyright 2018 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.keyboard_accessory.bar_component;

import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.ANIMATION_LISTENER;
import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.BAR_ITEMS;
import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.BOTTOM_OFFSET_PX;
import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.DISABLE_ANIMATIONS_FOR_TESTING;
import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.HAS_SUGGESTIONS;
import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.OBFUSCATED_CHILD_AT_CALLBACK;
import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.ON_TOUCH_EVENT_CALLBACK;
import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.SHEET_OPENER_ITEM;
import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.SHOW_SWIPING_IPH;
import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.SKIP_CLOSING_ANIMATION;
import static org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.VISIBLE;

import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;

import org.chromium.base.TraceEvent;
import org.chromium.chrome.browser.keyboard_accessory.AccessoryAction;
import org.chromium.chrome.browser.keyboard_accessory.ManualFillingMetricsRecorder;
import org.chromium.chrome.browser.keyboard_accessory.R;
import org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryCoordinator.BarVisibilityDelegate;
import org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryCoordinator.TabSwitchingDelegate;
import org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.AutofillBarItem;
import org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.BarItem;
import org.chromium.chrome.browser.keyboard_accessory.bar_component.KeyboardAccessoryProperties.SheetOpenerBarItem;
import org.chromium.chrome.browser.keyboard_accessory.button_group_component.KeyboardAccessoryButtonGroupCoordinator;
import org.chromium.chrome.browser.keyboard_accessory.data.KeyboardAccessoryData;
import org.chromium.chrome.browser.keyboard_accessory.data.KeyboardAccessoryData.Action;
import org.chromium.chrome.browser.keyboard_accessory.data.Provider;
import org.chromium.chrome.browser.keyboard_accessory.sheet_component.AccessorySheetCoordinator;
import org.chromium.components.autofill.AutofillDelegate;
import org.chromium.components.autofill.AutofillSuggestion;
import org.chromium.components.autofill.SuggestionType;
import org.chromium.components.feature_engagement.FeatureConstants;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyObservable;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.stream.StreamSupport;

/**
 * This is the second part of the controller of the keyboard accessory component. It is responsible
 * for updating the model based on backend calls and notify the backend if the model changes. From
 * the backend, it receives all actions that the accessory can perform (most prominently generating
 * passwords) and lets the model know of these actions and which callback to trigger when selecting
 * them.
 */
class KeyboardAccessoryMediator
        implements PropertyObservable.PropertyObserver<PropertyKey>,
                Provider.Observer<Action[]>,
                KeyboardAccessoryButtonGroupCoordinator.AccessoryTabObserver {
    private final PropertyModel mModel;
    private final BarVisibilityDelegate mBarVisibilityDelegate;
    private final AccessorySheetCoordinator.SheetVisibilityDelegate mSheetVisibilityDelegate;
    private final TabSwitchingDelegate mTabSwitcher;
    private Optional<Boolean> mHasFilteredTouchEvent = Optional.empty();

    KeyboardAccessoryMediator(
            PropertyModel model,
            BarVisibilityDelegate barVisibilityDelegate,
            AccessorySheetCoordinator.SheetVisibilityDelegate sheetVisibilityDelegate,
            TabSwitchingDelegate tabSwitcher,
            KeyboardAccessoryButtonGroupCoordinator.SheetOpenerCallbacks sheetOpenerCallbacks) {
        mModel = model;
        mBarVisibilityDelegate = barVisibilityDelegate;
        mSheetVisibilityDelegate = sheetVisibilityDelegate;
        mTabSwitcher = tabSwitcher;

        // Add mediator as observer so it can use model changes as signal for accessory visibility.
        mModel.set(OBFUSCATED_CHILD_AT_CALLBACK, this::onSuggestionObfuscatedAt);
        mModel.set(ON_TOUCH_EVENT_CALLBACK, this::onTouchEvent);
        mModel.set(SHEET_OPENER_ITEM, new SheetOpenerBarItem(sheetOpenerCallbacks));
        mModel.set(ANIMATION_LISTENER, mBarVisibilityDelegate::onBarFadeInAnimationEnd);
        mModel.get(BAR_ITEMS).add(mModel.get(SHEET_OPENER_ITEM));
        mModel.addObserver(this);
    }

    /**
     * Creates an observer object that refreshes the accessory bar items when a connected provider
     * notifies it about new {@link AutofillSuggestion}s. It ensures the delegate receives
     * interactions with the view representing a suggestion.
     *
     * @param delegate A {@link AutofillDelegate}.
     * @return A {@link Provider.Observer} accepting only {@link AutofillSuggestion}s.
     */
    Provider.Observer<List<AutofillSuggestion>> createAutofillSuggestionsObserver(
            AutofillDelegate delegate) {
        return (@AccessoryAction int typeId, List<AutofillSuggestion> suggestions) -> {
            assert typeId == AccessoryAction.AUTOFILL_SUGGESTION
                    : "Autofill suggestions observer received wrong data: " + typeId;
            List<BarItem> retainedItems = collectItemsToRetain(AccessoryAction.AUTOFILL_SUGGESTION);
            retainedItems.addAll(toBarItems(suggestions, delegate));
            retainedItems.add(retainedItems.size(), mModel.get(SHEET_OPENER_ITEM));
            mModel.get(BAR_ITEMS).set(retainedItems);
            mModel.set(HAS_SUGGESTIONS, barHasSuggestions());
        };
    }

    private boolean barHasSuggestions() {
        for (BarItem barItem : mModel.get(BAR_ITEMS)) {
            if (barItem.getViewType() == BarItem.Type.SUGGESTION) {
                return true;
            }
        }
        return false;
    }

    @Override
    public void onItemAvailable(
            @AccessoryAction int typeId, KeyboardAccessoryData.Action[] actions) {
        TraceEvent.begin("KeyboardAccessoryMediator#onItemAvailable");
        assert typeId == AccessoryAction.CREDMAN_CONDITIONAL_UI_REENTRY
                        || typeId == AccessoryAction.GENERATE_PASSWORD_AUTOMATIC
                : "Did not specify which Action type has been updated.";
        List<BarItem> retainedItems = collectItemsToRetain(typeId);
        retainedItems.addAll(
                typeId == AccessoryAction.CREDMAN_CONDITIONAL_UI_REENTRY ? retainedItems.size() : 0,
                toBarItems(actions));
        retainedItems.add(retainedItems.size(), mModel.get(SHEET_OPENER_ITEM));
        mModel.get(BAR_ITEMS).set(retainedItems);
        mModel.set(HAS_SUGGESTIONS, barHasSuggestions());
        TraceEvent.end("KeyboardAccessoryMediator#onItemAvailable");
    }

    private List<BarItem> collectItemsToRetain(@AccessoryAction int actionType) {
        List<BarItem> retainedItems = new ArrayList<>();
        for (BarItem item : mModel.get(BAR_ITEMS)) {
            if (item.getAction() == null) continue;
            if (item.getAction().getActionType() == actionType) continue;
            retainedItems.add(item);
        }
        return retainedItems;
    }

    /**
     * Next to the regular suggestion that we always want to show, there is a number of special
     * suggestions which we want to suppress (e.g. replaced entry points, old warnings, separators).
     *
     * @param suggestion This {@link AutofillSuggestion} will be checked for usefulness.
     * @return True iff the suggestion should be displayed.
     */
    private boolean shouldShowSuggestion(AutofillSuggestion suggestion) {
        switch (suggestion.getSuggestionType()) {
            case SuggestionType.INSECURE_CONTEXT_PAYMENT_DISABLED_MESSAGE:
                // The insecure context warning has a replacement in the fallback sheet.
            case SuggestionType.TITLE:
            case SuggestionType.SEPARATOR:
            case SuggestionType.UNDO_OR_CLEAR:
            case SuggestionType.ALL_SAVED_PASSWORDS_ENTRY:
            case SuggestionType.GENERATE_PASSWORD_ENTRY:
            case SuggestionType.SHOW_ACCOUNT_CARDS:
            case SuggestionType.MANAGE_ADDRESS:
            case SuggestionType.MANAGE_CREDIT_CARD:
            case SuggestionType.MANAGE_IBAN:
            case SuggestionType.MANAGE_PLUS_ADDRESS:
                return false;
            case SuggestionType.AUTOCOMPLETE_ENTRY:
            case SuggestionType.PASSWORD_ENTRY:
            case SuggestionType.DATALIST_ENTRY:
            case SuggestionType.SCAN_CREDIT_CARD:
            case SuggestionType.ACCOUNT_STORAGE_PASSWORD_ENTRY:
                return true;
        }
        return true; // If it's not a special id, show the regular suggestion!
    }

    private List<AutofillBarItem> toBarItems(
            List<AutofillSuggestion> suggestions, AutofillDelegate delegate) {
        List<AutofillBarItem> barItems = new ArrayList<>(suggestions.size());
        for (int position = 0; position < suggestions.size(); ++position) {
            AutofillSuggestion suggestion = suggestions.get(position);
            if (!shouldShowSuggestion(suggestion)) continue;
            barItems.add(new AutofillBarItem(suggestion, createAutofillAction(delegate, position)));
        }

        // Annotates the first suggestion in with an in-product help bubble. For password
        // suggestions, the first suggestion is usually autofilled and therefore, the second
        // element is annotated.
        // This doesn't necessary mean that the IPH bubble will be shown - a final check will be
        // performed right before the bubble can be displayed.
        boolean skippedFirstPasswordItem = false;
        for (AutofillBarItem barItem : barItems) {
            if (!skippedFirstPasswordItem && containsPasswordInfo(barItem.getSuggestion())) {
                // For password suggestions, we want to educate about the 2nd entry.
                skippedFirstPasswordItem = true;
                continue;
            }
            barItem.setFeatureForIPH(getFeatureBySuggestionId(barItem.getSuggestion()));
            break; // Only set IPH for one suggestions in the bar.
        }

        return barItems;
    }

    private Collection<BarItem> toBarItems(Action[] actions) {
        List<BarItem> barItems = new ArrayList<>(actions.length);
        for (Action action : actions) {
            barItems.add(
                    new BarItem(
                            toBarItemType(action.getActionType()),
                            action,
                            getCaptionId(action.getActionType())));
        }
        return barItems;
    }

    private Action createAutofillAction(AutofillDelegate delegate, int pos) {
        return new Action(
                AccessoryAction.AUTOFILL_SUGGESTION,
                result -> {
                    ManualFillingMetricsRecorder.recordActionSelected(
                            AccessoryAction.AUTOFILL_SUGGESTION);
                    delegate.suggestionSelected(pos);
                },
                result -> delegate.deleteSuggestion(pos));
    }

    private @BarItem.Type int toBarItemType(@AccessoryAction int accessoryAction) {
        switch (accessoryAction) {
            case AccessoryAction.AUTOFILL_SUGGESTION:
                return BarItem.Type.SUGGESTION;
            case AccessoryAction.GENERATE_PASSWORD_AUTOMATIC:
                return BarItem.Type.ACTION_BUTTON;
            case AccessoryAction.CREDMAN_CONDITIONAL_UI_REENTRY:
                return BarItem.Type.ACTION_CHIP;
            case AccessoryAction.MANAGE_PASSWORDS: // Intentional fallthrough - no view defined.
            case AccessoryAction.CROSS_DEVICE_PASSKEY:
            case AccessoryAction.COUNT:
                throw new IllegalArgumentException("No view defined for :" + accessoryAction);
        }
        throw new IllegalArgumentException("Unhandled action type:" + accessoryAction);
    }

    void show() {
        mModel.set(SKIP_CLOSING_ANIMATION, false);
        mModel.set(VISIBLE, true);
    }

    void skipClosingAnimationOnce() {
        mModel.set(SKIP_CLOSING_ANIMATION, true);
    }

    void dismiss() {
        mTabSwitcher.closeActiveTab();
        mModel.set(VISIBLE, false);
        if (!mHasFilteredTouchEvent.orElse(true)) {
            // Log the metric if the accessory received touch events, but none of them were
            // filtered.
            ManualFillingMetricsRecorder.recordHasFilteredTouchEvents(false);
        }
        mHasFilteredTouchEvent = Optional.empty();
    }

    @Override
    public void onPropertyChanged(
            PropertyObservable<PropertyKey> source, @Nullable PropertyKey propertyKey) {
        // Update the visibility only if we haven't set it just now.
        if (propertyKey == VISIBLE) {
            mModel.set(SHOW_SWIPING_IPH, false); // Reset IPH if visibility changes.
            // When the accessory just (dis)appeared, there should be no active tab.
            mTabSwitcher.closeActiveTab();
            if (!mModel.get(VISIBLE)) {
                // TODO(fhorschig|ioanap): Maybe the generation bridge should take care of that.
                onItemAvailable(AccessoryAction.GENERATE_PASSWORD_AUTOMATIC, new Action[0]);
            }
            return;
        }
        if (propertyKey == BOTTOM_OFFSET_PX
                || propertyKey == SHEET_OPENER_ITEM
                || propertyKey == SKIP_CLOSING_ANIMATION
                || propertyKey == DISABLE_ANIMATIONS_FOR_TESTING
                || propertyKey == OBFUSCATED_CHILD_AT_CALLBACK
                || propertyKey == SHOW_SWIPING_IPH
                || propertyKey == HAS_SUGGESTIONS
                || propertyKey == ANIMATION_LISTENER) {
            return;
        }
        assert false : "Every property update needs to be handled explicitly!";
    }

    @Override
    public void onActiveTabChanged(Integer activeTab) {
        if (activeTab == null) {
            return;
        }
        mSheetVisibilityDelegate.onChangeAccessorySheet(activeTab);
    }

    private void onSuggestionObfuscatedAt(Integer indexOfLast) {
        // Show IPH if at least one entire item (suggestion or fallback) can be revealed by swiping.
        mModel.set(SHOW_SWIPING_IPH, indexOfLast <= mModel.get(BAR_ITEMS).size() - 2);
    }

    private void onTouchEvent(boolean eventFiltered) {
        if (!eventFiltered) {
            mHasFilteredTouchEvent = Optional.of(mHasFilteredTouchEvent.orElse(false));
            return;
        }
        if (!mHasFilteredTouchEvent.orElse(false)) {
            // Log the metric if none of the previous touch events were filtered.
            ManualFillingMetricsRecorder.recordHasFilteredTouchEvents(true);
        }
        mHasFilteredTouchEvent = Optional.of(true);
    }

    /**
     * @return True if neither suggestions nor tabs are available.
     */
    boolean empty() {
        return !hasSuggestions() && !mTabSwitcher.hasTabs();
    }

    /**
     * @return True if the bar contains any suggestions next to the tabs.
     */
    private boolean hasSuggestions() {
        return mModel.get(BAR_ITEMS).size() > 1; // Ignore tab switcher item.
    }

    void setBottomOffset(@Px int bottomOffset) {
        mModel.set(BOTTOM_OFFSET_PX, bottomOffset);
    }

    boolean isShown() {
        return mModel.get(VISIBLE);
    }

    boolean hasActiveTab() {
        return mModel.get(VISIBLE) && mTabSwitcher.getActiveTab() != null;
    }

    PropertyModel getModelForTesting() {
        return mModel;
    }

    private static String getFeatureBySuggestionId(AutofillSuggestion suggestion) {
        // If the suggestion has an explicit IPH feature defined, prefer that over the default IPH
        // features.
        if (suggestion.getFeatureForIPH() != null && !suggestion.getFeatureForIPH().isEmpty()) {
            return suggestion.getFeatureForIPH();
        }
        if (containsPasswordInfo(suggestion)) {
            return FeatureConstants.KEYBOARD_ACCESSORY_PASSWORD_FILLING_FEATURE;
        }
        if (containsCreditCardInfo(suggestion)) {
            return FeatureConstants.KEYBOARD_ACCESSORY_PAYMENT_FILLING_FEATURE;
        }
        if (containsAddressInfo(suggestion)) {
            return FeatureConstants.KEYBOARD_ACCESSORY_ADDRESS_FILL_FEATURE;
        }
        return null;
    }

    private static boolean containsPasswordInfo(AutofillSuggestion suggestion) {
        return suggestion.getSuggestionType() == SuggestionType.PASSWORD_ENTRY;
    }

    private static boolean containsCreditCardInfo(AutofillSuggestion suggestion) {
        return suggestion.getSuggestionType() == SuggestionType.CREDIT_CARD_ENTRY;
    }

    private static boolean containsAddressInfo(AutofillSuggestion suggestion) {
        return suggestion.getSuggestionType() == SuggestionType.ADDRESS_ENTRY;
    }

    private @StringRes int getCaptionId(@AccessoryAction int actionType) {
        switch (actionType) {
            case AccessoryAction.GENERATE_PASSWORD_AUTOMATIC:
                return R.string.password_generation_accessory_button;
            case AccessoryAction.CREDMAN_CONDITIONAL_UI_REENTRY:
                return getCaptionIdForCredManEntry();
            case AccessoryAction.AUTOFILL_SUGGESTION:
            case AccessoryAction.COUNT:
            case AccessoryAction.TOGGLE_SAVE_PASSWORDS:
            case AccessoryAction.USE_OTHER_PASSWORD:
            case AccessoryAction.GENERATE_PASSWORD_MANUAL:
            case AccessoryAction.MANAGE_ADDRESSES:
            case AccessoryAction.MANAGE_CREDIT_CARDS:
            case AccessoryAction.MANAGE_PASSWORDS:
            case AccessoryAction.CROSS_DEVICE_PASSKEY:
                assert false : "No caption defined for accessory action: " + actionType;
        }
        assert false : "Define a title for accessory action: " + actionType;
        return 0;
    }

    private @StringRes int getCaptionIdForCredManEntry() {
        Predicate<BarItem> hasWebAuthnCredential =
                barItem ->
                        barItem.getViewType() == BarItem.Type.SUGGESTION
                                && ((AutofillBarItem) barItem).getSuggestion().getSuggestionType()
                                        == SuggestionType.WEBAUTHN_CREDENTIAL;
        return StreamSupport.stream(mModel.get(BAR_ITEMS).spliterator(), true)
                        .anyMatch(hasWebAuthnCredential)
                ? R.string.more_passkeys
                : R.string.select_passkey;
    }
}