chromium/components/messages/android/java/src/org/chromium/components/messages/MessageContainer.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.components.messages;

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.widget.FrameLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;

import org.chromium.base.Log;
import org.chromium.base.TraceEvent;

/** Container holding messages. */
public class MessageContainer extends FrameLayout {
    private static final String TAG = "MessageContainer";

    interface MessageContainerA11yDelegate {
        void onA11yFocused();

        void onA11yFocusCleared();

        void onA11yDismiss();
    }

    class MessageContainerA11yDelegateProxy extends AccessibilityDelegate {
        private int mFocusedView;

        @Override
        public void onInitializeAccessibilityEvent(
                @NonNull View host, @NonNull AccessibilityEvent event) {
            handleEvent(event);
            super.onInitializeAccessibilityEvent(host, event);
        }

        @Override
        public boolean onRequestSendAccessibilityEvent(
                @NonNull ViewGroup host, @NonNull View child, @NonNull AccessibilityEvent event) {
            handleEvent(event);
            return super.onRequestSendAccessibilityEvent(host, child, event);
        }

        private void handleEvent(@NonNull AccessibilityEvent event) {
            if (mA11yDelegate == null) return;
            if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED) {
                assert mFocusedView == 0 : "No other view should be focused";
                mFocusedView++;
                mA11yDelegate.onA11yFocused();
            } else if (event.getEventType()
                    == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED) {
                assert mFocusedView == 1 : "One view must be focused";
                mFocusedView--;
                mA11yDelegate.onA11yFocusCleared();
            }
        }
    }

    private MessageContainerA11yDelegate mA11yDelegate;
    private boolean mIsInitializingLayout;
    private int mA11yDismissActionId = NO_ID;

    public MessageContainer(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        setAccessibilityDelegate(new MessageContainerA11yDelegateProxy());
    }

    /**
     * Show a given message view on the screen. There should be no more than one unique view
     * before adding a message.
     * @param view The message view to display on the screen.
     */
    void addMessage(View view) {
        if (indexOfChild(view) != -1) {
            throw new IllegalStateException("Should not contain the target view when adding.");
        }
        int index = 0;
        if (getChildCount() > 1) {
            throw new IllegalStateException(
                    "Should not contain more than 2 views when adding a new message.");
        } else if (getChildCount() == 1) {
            View cur = getChildAt(0);
            index = cur.getElevation() > view.getElevation() ? 1 : 0;
        }
        super.addView(view, index);
        onChildCountChanged();

        // TODO(crbug.com/40749472): clipChildren should be set to false only when the message is in
        // motion.
    }

    /**
     * Remove the given message view, which is being shown inside the container.
     * @param view The message which should be removed.
     */
    void removeMessage(View view) {
        if (indexOfChild(view) == -1) {
            throw new IllegalStateException("The given view is not being shown.");
        }
        super.removeView(view);
        if (getChildCount() == 0) {
            mA11yDelegate = null;
        }
        onChildCountChanged();
    }

    private void onChildCountChanged() {
        ViewCompat.removeAccessibilityAction(this, mA11yDismissActionId);
        if (getChildCount() == 0) return;
        String label =
                getResources()
                        .getString(
                                getChildCount() == 1
                                        ? R.string.chrome_dismiss
                                        : R.string.message_dismiss_and_show_next);
        mA11yDismissActionId =
                ViewCompat.addAccessibilityAction(
                        this,
                        label,
                        (v, c) -> {
                            if (mA11yDelegate != null) {
                                assert getChildCount() != 0;
                                mA11yDelegate.onA11yDismiss();
                                return true;
                            }
                            return false;
                        });
    }

    public int getMessageBannerHeight() {
        assert getChildCount() > 0;
        // TODO(crbug.com/40877229): remove this log after fix.
        if (getChildAt(0) == null) {
            Log.w(TAG, "Null child in message container; child count %s", getChildCount());
        }
        return getChildAt(0).getHeight();
    }

    public int getMessageShadowTopMargin() {
        return getResources().getDimensionPixelOffset(R.dimen.message_shadow_top_margin);
    }

    @Override
    public void setLayoutParams(ViewGroup.LayoutParams params) {
        try (TraceEvent e = TraceEvent.scoped("MessageContainer.setLayoutParams")) {
            super.setLayoutParams(params);
        }
    }

    void setA11yDelegate(MessageContainerA11yDelegate a11yDelegate) {
        mA11yDelegate = a11yDelegate;
    }

    View getSiblingView(View current) {
        assert getChildCount() > 1;
        int idx = indexOfChild(current);
        assert idx != -1;
        return getChildAt(1 - idx);
    }

    /**
     * Runs a {@link Runnable} after the message's initial layout. If the view is already laid out,
     * the {@link Runnable} will be called immediately.
     *
     * @param runnable The {@link Runnable}.
     * @return True if the callback is triggered immediately (i.e. synchronously).
     */
    boolean runAfterInitialMessageLayout(Runnable runnable) {
        View view = getChildAt(0);
        assert view != null;
        if (view.getHeight() > 0) {
            mIsInitializingLayout = false;
            runnable.run();
            return true;
        }

        mIsInitializingLayout = true;
        view.addOnLayoutChangeListener(
                new OnLayoutChangeListener() {
                    @Override
                    public void onLayoutChange(
                            View v,
                            int left,
                            int top,
                            int right,
                            int bottom,
                            int oldLeft,
                            int oldTop,
                            int oldRight,
                            int oldBottom) {
                        if (v.getHeight() == 0) return;

                        runnable.run();
                        v.removeOnLayoutChangeListener(this);
                        mIsInitializingLayout = false;
                    }
                });
        return false;
    }

    /**
     * Returns whether container is initializing its layout for a new added view. Clients should not
     * call {@link #runAfterInitialMessageLayout(Runnable)} when it returns true.
     * @return True if it is initializing layout.
     */
    public boolean isIsInitializingLayout() {
        return mIsInitializingLayout;
    }

    /** Call {@link #addMessage(View)} instead in order to prevent from uncontrolled add. */
    @Override
    @Deprecated
    public final void addView(View view) {
        throw new RuntimeException("Use addMessage instead.");
    }

    /** Call {@link #removeMessage(View)} instead in order to prevent from uncontrolled remove. */
    @Override
    @Deprecated
    public final void removeView(View view) {
        throw new RuntimeException("Use removeMessage instead.");
    }

    public int getA11yDismissActionIdForTesting() {
        return mA11yDismissActionId;
    }
}