chromium/chrome/android/features/keyboard_accessory/internal/java/src/org/chromium/chrome/browser/keyboard_accessory/bar_component/KeyboardAccessoryView.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.ui.base.LocalizationUtils.isLayoutRtl;

import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewPropertyAnimator;
import android.view.accessibility.AccessibilityEvent;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.OvershootInterpolator;
import android.widget.LinearLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Px;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import org.chromium.base.Callback;
import org.chromium.base.TraceEvent;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.keyboard_accessory.R;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.ui.widget.ViewRectProvider;

/**
 * The Accessory sitting above the keyboard and below the content area. It is used for autofill
 * suggestions and manual entry points assisting the user in filling forms.
 */
class KeyboardAccessoryView extends LinearLayout {
    private static final int ARRIVAL_ANIMATION_DURATION_MS = 300;
    private static final float ARRIVAL_ANIMATION_BOUNCE_LENGTH_DIP = 200f;
    private static final float ARRIVAL_ANIMATION_TENSION = 1f;
    private static final int FADE_ANIMATION_DURATION_MS = 150; // Total duration of show/hide.
    private static final int HIDING_ANIMATION_DELAY_MS = 50; // Shortens animation duration.

    private Tracker mFeatureEngagementTracker;
    private Callback<Integer> mObfuscatedLastChildAt;
    private Callback<Boolean> mOnTouchEvent;
    private ObjectAnimator mAnimator;
    private AnimationListener mAnimationListener;
    private ViewPropertyAnimator mRunningAnimation;
    private float mLastBarItemsViewPosition;
    private boolean mShouldSkipClosingAnimation;
    private boolean mDisableAnimations;
    private boolean mAllowClicksWhileObscured;

    protected RecyclerView mBarItemsView;

    /** Interface that allows to react to animations. */
    interface AnimationListener {
        /**
         * Called if the accessory bar stopped fading in. The fade-in only happens sometimes, e.g.
         * if the bar is already visible or animations are disabled, this signal is not issued.
         */
        void onFadeInEnd();
    }

    // Records the first time a user scrolled to suppress an IPH explaining how scrolling works.
    private final RecyclerView.OnScrollListener mScrollingIphCallback =
            new RecyclerView.OnScrollListener() {
                @Override
                public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
                    if (newState != RecyclerView.SCROLL_STATE_IDLE) {
                        mBarItemsView.removeOnScrollListener(mScrollingIphCallback);
                        KeyboardAccessoryIPHUtils.emitScrollingEvent(mFeatureEngagementTracker);
                    }
                }
            };

    /**
     * This decoration ensures that the last item is right-aligned. To do this, it subtracts the
     * widths, margins and offsets of all items in the recycler view from the RecyclerView's total
     * width. If the items fill the whole recycler view, the last item uses the same offset as all
     * other items.
     */
    private class StickyLastItemDecoration extends RecyclerView.ItemDecoration {
        private final int mHorizontalMargin;

        StickyLastItemDecoration(@Px int minimalLeadingHorizontalMargin) {
            this.mHorizontalMargin = minimalLeadingHorizontalMargin;
        }

        @Override
        public void getItemOffsets(
                Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
            if (isLayoutRtl()) {
                outRect.right = getItemOffsetInternal(view, parent, state);
            } else {
                outRect.left = getItemOffsetInternal(view, parent, state);
            }
        }

        private int getItemOffsetInternal(
                final View view, final RecyclerView parent, RecyclerView.State state) {
            if (!isLastItem(parent, view, parent.getAdapter().getItemCount())) {
                return mHorizontalMargin;
            }
            if (view.getWidth() == 0 && state.didStructureChange()) {
                // When the RecyclerView is first created, its children aren't measured yet and miss
                // dimensions. Therefore, estimate the offset and recalculate after UI has loaded.
                view.post(parent::invalidateItemDecorations);
                return parent.getWidth() - estimateLastElementWidth(view);
            }
            return Math.max(getSpaceLeftInParent(parent), mHorizontalMargin);
        }

        private int getSpaceLeftInParent(RecyclerView parent) {
            int spaceLeftInParent = parent.getWidth();
            spaceLeftInParent -= getOccupiedSpaceByChildren(parent);
            spaceLeftInParent -= getOccupiedSpaceByChildrenOffsets(parent);
            spaceLeftInParent -= parent.getPaddingEnd() + parent.getPaddingStart();
            return spaceLeftInParent;
        }

        private int estimateLastElementWidth(View view) {
            assert view instanceof ViewGroup;
            return ((ViewGroup) view).getChildCount()
                    * getContext()
                            .getResources()
                            .getDimensionPixelSize(R.dimen.keyboard_accessory_tab_size);
        }

        private int getOccupiedSpaceByChildren(RecyclerView parent) {
            int occupiedSpace = 0;
            for (int i = 0; i < parent.getChildCount(); i++) {
                occupiedSpace += getOccupiedSpaceForView(parent.getChildAt(i));
            }
            return occupiedSpace;
        }

        private int getOccupiedSpaceForView(View view) {
            int occupiedSpace = view.getWidth();
            ViewGroup.LayoutParams lp = view.getLayoutParams();
            if (lp instanceof MarginLayoutParams) {
                occupiedSpace += ((MarginLayoutParams) lp).leftMargin;
                occupiedSpace += ((MarginLayoutParams) lp).rightMargin;
            }
            return occupiedSpace;
        }

        private int getOccupiedSpaceByChildrenOffsets(RecyclerView parent) {
            return (parent.getChildCount() - 1) * mHorizontalMargin;
        }

        private boolean isLastItem(RecyclerView parent, View view, int itemCount) {
            return parent.getChildAdapterPosition(view) == itemCount - 1;
        }
    }

    /** Constructor for inflating from XML. */
    public KeyboardAccessoryView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        final boolean isViewObscured =
                (event.getFlags()
                                & (MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED
                                        | MotionEvent.FLAG_WINDOW_IS_OBSCURED))
                        != 0;
        // The event is filtered out when the keyboard accessory view is fully or partially obscured
        // given that no user education bubbles are shown to the user.
        final boolean shouldFilterEvent = isViewObscured && !mAllowClicksWhileObscured;
        mOnTouchEvent.onResult(shouldFilterEvent);

        if (!ChromeFeatureList.isEnabled(
                ChromeFeatureList.AUTOFILL_ENABLE_SECURITY_TOUCH_EVENT_FILTERING_ANDROID)) {
            return super.onInterceptTouchEvent(event);
        }
        if (shouldFilterEvent) {
            return true;
        }
        // When keyboard accessory view is fully or partially obsured, clicks are allowed only if
        // the user education bubble is being displayed. After the first click
        // (MotionEvent.ACTION_UP), such motion events start to get filtered again. Please note that
        // a user click produces 2 motion events: MotionEvent.ACTION_DOWN and then
        // MotionEvent.ACTION_UP.
        if (event.getAction() == MotionEvent.ACTION_UP) {
            mAllowClicksWhileObscured = false;
        }

        return super.onInterceptTouchEvent(event);
    }

    @Override
    protected void onFinishInflate() {
        TraceEvent.begin("KeyboardAccessoryView#onFinishInflate");
        super.onFinishInflate();
        sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);

        mBarItemsView = findViewById(R.id.bar_items_view);
        initializeHorizontalRecyclerView(mBarItemsView);

        // Apply RTL layout changes to the view's children:
        int layoutDirection = isLayoutRtl() ? View.LAYOUT_DIRECTION_RTL : View.LAYOUT_DIRECTION_LTR;
        findViewById(R.id.accessory_bar_contents).setLayoutDirection(layoutDirection);
        mBarItemsView.setLayoutDirection(layoutDirection);

        // Set listener's to touch/click events so they are not propagated to the page below.
        setOnTouchListener(
                (view, motionEvent) -> {
                    performClick(); // Setting a touch listener requires this call which is a NoOp.
                    // Return that the motionEvent was consumed and needs no further handling.
                    return true;
                });
        setOnClickListener(view -> {});
        setClickable(false); // Disables the "Double-tap to activate" Talkback reading.
        setSoundEffectsEnabled(false);

        int pad = getResources().getDimensionPixelSize(R.dimen.keyboard_accessory_bar_item_padding);
        // Ensure the last element (although scrollable) is always end-aligned.
        mBarItemsView.addItemDecoration(new StickyLastItemDecoration(pad));
        mBarItemsView.addOnScrollListener(mScrollingIphCallback);

        // Remove any paddings that might be inherited since this messes up the fading edge.
        mBarItemsView.setPaddingRelative(0, 0, 0, 0);
        TraceEvent.end("KeyboardAccessoryView#onFinishInflate");
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        // Request update for the offset of the icons at the end of the accessory bar:
        mBarItemsView.post(mBarItemsView::invalidateItemDecorations);
    }

    void setFeatureEngagementTracker(Tracker tracker) {
        assert tracker != null : "Tracker must not be null";
        mFeatureEngagementTracker = tracker;
    }

    Tracker getFeatureEngagementTracker() {
        assert mFeatureEngagementTracker != null : "Attempting to access null Tracker";
        return mFeatureEngagementTracker;
    }

    void setVisible(boolean visible) {
        TraceEvent.begin("KeyboardAccessoryView#setVisible");
        if (!visible || getVisibility() != VISIBLE) mBarItemsView.scrollToPosition(0);
        if (visible) {
            show();
            mBarItemsView.post(mBarItemsView::invalidateItemDecorations);
            // Animate the suggestions only if the bar wasn't visible already.
            if (getVisibility() != View.VISIBLE) animateSuggestionArrival();
        } else {
            hide();
        }
        TraceEvent.end("KeyboardAccessoryView#setVisible");
    }

    void setSkipClosingAnimation(boolean shouldSkipClosingAnimation) {
        mShouldSkipClosingAnimation = shouldSkipClosingAnimation;
    }

    void setAnimationListener(AnimationListener animationListener) {
        mAnimationListener = animationListener;
    }

    ViewRectProvider getSwipingIphRect() {
        View lastChild = getLastChild();
        if (lastChild == null) return null;
        ViewRectProvider provider = new ViewRectProvider(getLastChild());
        provider.setIncludePadding(true);
        return provider;
    }

    void setBottomOffset(int bottomOffset) {
        MarginLayoutParams params = (MarginLayoutParams) getLayoutParams();
        params.setMargins(params.leftMargin, params.topMargin, params.rightMargin, bottomOffset);
        setLayoutParams(params);
    }

    void setObfuscatedLastChildAt(Callback<Integer> obfuscatedLastChildAt) {
        mObfuscatedLastChildAt = obfuscatedLastChildAt;
    }

    void setOnTouchEventCallback(Callback<Boolean> onTouchEvent) {
        mOnTouchEvent = onTouchEvent;
    }

    void disableAnimationsForTesting() {
        mDisableAnimations = true;
    }

    boolean areAnimationsDisabled() {
        return mDisableAnimations;
    }

    void setAllowClicksWhileObscured(boolean allowClicksWhileObscured) {
        mAllowClicksWhileObscured = allowClicksWhileObscured;
    }

    boolean areClicksAllowedWhenObscured() {
        return mAllowClicksWhileObscured;
    }

    void setAccessibilityMessage(boolean hasSuggestions) {
        setContentDescription(
                getContext()
                        .getString(
                                hasSuggestions
                                        ? R.string.autofill_keyboard_accessory_content_description
                                        : R.string
                                                .autofill_keyboard_accessory_content_fallback_description));
    }

    void setBarItemsAdapter(RecyclerView.Adapter adapter) {
        // Make sure the view updates the fallback icon padding whenever new items arrive.
        adapter.registerAdapterDataObserver(
                new RecyclerView.AdapterDataObserver() {
                    @Override
                    public void onItemRangeChanged(int positionStart, int itemCount) {
                        super.onItemRangeChanged(positionStart, itemCount);
                        mBarItemsView.scrollToPosition(0);
                        mBarItemsView.invalidateItemDecorations();
                        onItemsChanged();
                    }
                });
        mBarItemsView.setAdapter(adapter);
    }

    private void show() {
        TraceEvent.begin("KeyboardAccessoryView#show");
        bringToFront(); // Needs to overlay every component and the bottom sheet - like a keyboard.
        if (mRunningAnimation != null) {
            mRunningAnimation.cancel();
            mRunningAnimation = null;
        }
        if (areAnimationsDisabled()) {
            mRunningAnimation = null;
            setVisibility(View.VISIBLE);
            return;
        }
        if (getVisibility() != View.VISIBLE) setAlpha(0f);
        mRunningAnimation =
                animate()
                        .alpha(1f)
                        .setDuration(FADE_ANIMATION_DURATION_MS)
                        .setInterpolator(new AccelerateInterpolator())
                        .withStartAction(() -> setVisibility(View.VISIBLE))
                        .withEndAction(
                                () -> {
                                    mAnimationListener.onFadeInEnd();
                                    mRunningAnimation = null;
                                });
        announceForAccessibility(getContentDescription());
        TraceEvent.end("KeyboardAccessoryView#show");
    }

    private void hide() {
        if (mRunningAnimation != null) {
            mRunningAnimation.cancel();
            mRunningAnimation = null;
        }
        if (mShouldSkipClosingAnimation || areAnimationsDisabled()) {
            mRunningAnimation = null;
            setVisibility(View.GONE);
            return;
        }
        mRunningAnimation =
                animate()
                        .alpha(0.0f)
                        .setInterpolator(new AccelerateInterpolator())
                        .setStartDelay(HIDING_ANIMATION_DELAY_MS)
                        .setDuration(FADE_ANIMATION_DURATION_MS - HIDING_ANIMATION_DELAY_MS)
                        .withEndAction(
                                () -> {
                                    setVisibility(View.GONE);
                                    mRunningAnimation = null;
                                });
    }

    private boolean isLastChildObfuscated() {
        View lastChild = getLastChild();
        RecyclerView.Adapter adapter = mBarItemsView.getAdapter();
        // The recycler view isn't ready yet, so no children can be considered:
        if (lastChild == null || adapter == null) return false;
        // The last child wasn't even rendered, so it's definitely not visible:
        if (mBarItemsView.indexOfChild(lastChild) < adapter.getItemCount()) return true;
        // The last child is partly off screen:
        return getLayoutDirection() == LAYOUT_DIRECTION_RTL
                ? lastChild.getX() < 0
                : lastChild.getX() + lastChild.getWidth() > mBarItemsView.getWidth();
    }

    private void onItemsChanged() {
        if (isLastChildObfuscated()) {
            mObfuscatedLastChildAt.onResult(mBarItemsView.indexOfChild(getLastChild()));
        }
    }

    private View getLastChild() {
        for (int i = mBarItemsView.getChildCount() - 1; i >= 0; --i) {
            View lastChild = mBarItemsView.getChildAt(i);
            if (lastChild == null) continue;
            return lastChild;
        }
        return null;
    }

    private void animateSuggestionArrival() {
        if (areAnimationsDisabled()) return;
        int bounceDirection = getLayoutDirection() == LAYOUT_DIRECTION_RTL ? 1 : -1;
        if (mAnimator != null && mAnimator.isRunning()) {
            mAnimator.cancel();
        } else {
            mLastBarItemsViewPosition = mBarItemsView.getX();
        }

        float start =
                mLastBarItemsViewPosition
                        - bounceDirection
                                * ARRIVAL_ANIMATION_BOUNCE_LENGTH_DIP
                                * getContext().getResources().getDisplayMetrics().density;
        mBarItemsView.setTranslationX(start);
        mAnimator =
                ObjectAnimator.ofFloat(
                        mBarItemsView, "translationX", start, mLastBarItemsViewPosition);
        mAnimator.setDuration(ARRIVAL_ANIMATION_DURATION_MS);
        mAnimator.setInterpolator(new OvershootInterpolator(ARRIVAL_ANIMATION_TENSION));
        mAnimator.start();
    }

    private void initializeHorizontalRecyclerView(RecyclerView recyclerView) {
        // Set horizontal layout.
        recyclerView.setLayoutManager(
                new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));

        int pad =
                getResources().getDimensionPixelSize(R.dimen.keyboard_accessory_horizontal_padding);

        // Remove all animations - the accessory shouldn't be visibly built anyway.
        recyclerView.setItemAnimator(null);

        recyclerView.setPaddingRelative(pad, 0, 0, 0);
    }

    @VisibleForTesting
    boolean hasRunningAnimation() {
        return mRunningAnimation != null;
    }
}