chromium/ui/android/java/src/org/chromium/ui/widget/Toast.java

// Copyright 2015 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.ui.widget;

import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.ContextWrapper;
import android.content.pm.ApplicationInfo;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;

import androidx.annotation.ColorInt;
import androidx.annotation.IntDef;
import androidx.annotation.StringRes;
import androidx.annotation.StyleRes;

import org.chromium.base.SysUtils;
import org.chromium.ui.R;
import org.chromium.ui.display.DisplayAndroid;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * Toast wrapper, makes sure toasts are not HW accelerated on low-end devices and presented
 * correctly (i.e. use VrToast while in virtual reality).
 *
 * Can (and should) also be used for Chromium-related additions and extensions.
 *
 * When {@link ToastManager} is enabled, priority {@code HIGH} can be used when it is critical
 * that the toast be shown sooner than those of priority {@code NORMAL} queued for their turn.
 * See {@link ToastManager} for more details.
 */
public class Toast {

    public static final int LENGTH_SHORT = android.widget.Toast.LENGTH_SHORT;
    public static final int LENGTH_LONG = android.widget.Toast.LENGTH_LONG;

    private static int sExtraYOffset;

    /** The different priorities that a toast can have. */
    @IntDef({ToastPriority.HIGH, ToastPriority.NORMAL})
    @Retention(RetentionPolicy.SOURCE)
    public @interface ToastPriority {
        int HIGH = 0;
        int NORMAL = 1;
    }

    private android.widget.Toast mToast;
    private ViewGroup mSWLayout;
    private @ToastPriority int mPriority;
    private CharSequence mText;

    public Toast(Context context, View toastView) {
        if (SysUtils.isLowEndDevice()) {
            // Don't HW accelerate Toasts. Unfortunately the only way to do that is to make
            // toast.getView().getContext().getApplicationInfo() return lies to prevent
            // WindowManagerGlobal.addView() from adding LayoutParams.FLAG_HARDWARE_ACCELERATED.
            mSWLayout =
                    new FrameLayout(
                            new ContextWrapper(context) {
                                @Override
                                public ApplicationInfo getApplicationInfo() {
                                    ApplicationInfo info =
                                            new ApplicationInfo(super.getApplicationInfo());

                                    // On M+ the condition we need to fail is
                                    // "flags & ApplicationInfo.FLAG_HARDWARE_ACCELERATED"
                                    info.flags &= ~ApplicationInfo.FLAG_HARDWARE_ACCELERATED;
                                    return info;
                                }
                            });
        }

        mToast = UiWidgetFactory.getInstance().createToast(context);
        setView(toastView);
        mToast.setGravity(
                mToast.getGravity(), mToast.getXOffset(), mToast.getYOffset() + sExtraYOffset);
    }

    public void show() {
        ToastManager.getInstance().requestShow(this);
    }

    public void cancel() {
        ToastManager.getInstance().cancel(this);
    }

    public void setView(View view) {
        if (mSWLayout != null) {
            mSWLayout.removeAllViews();
            if (view != null) {
                mSWLayout.addView(view, WRAP_CONTENT, WRAP_CONTENT);
                mToast.setView(mSWLayout);
            } else {
                // When null view is set we propagate it to the toast to trigger appropriate
                // handling (for example show() throws an exception when view is null).
                mToast.setView(null);
            }
        } else {
            mToast.setView(view);
        }
    }

    public View getView() {
        if (mToast.getView() == null) {
            return null;
        }
        if (mSWLayout != null) {
            return mSWLayout.getChildAt(0);
        } else {
            return mToast.getView();
        }
    }

    public void setDuration(int duration) {
        mToast.setDuration(duration);
    }

    int getDuration() {
        return mToast.getDuration();
    }

    void setPriority(@ToastPriority int priority) {
        mPriority = priority;
    }

    @ToastPriority
    int getPriority() {
        return mPriority;
    }

    void setText(CharSequence text) {
        mText = text;
    }

    CharSequence getText() {
        return mText;
    }

    android.widget.Toast getAndroidToast() {
        return mToast;
    }

    @SuppressLint("RtlHardcoded")
    private void anchor(Context context, View anchoredView) {
        // TODO(crbug.com/40832378): The follow logic has several problems, especially that
        // Toast#setGravity requires screen coordinates. Would probably be better if reworked to use
        // something like AnchoredPopupWindow.

        // Use screen coordinates/size because Toast#setGravity takes screen coordinates, not
        // window coordinates.
        DisplayAndroid displayAndroid = DisplayAndroid.getNonMultiDisplay(context);
        final int screenWidth = displayAndroid.getDisplayWidth();
        final int screenHeight = displayAndroid.getDisplayHeight();
        final int[] screenPos = new int[2];
        anchoredView.getLocationOnScreen(screenPos);

        // This is incorrect to use the view size. The logic below really wants to use the toast
        // size, but at this point it hasn't measured yet. Use the view instead as a poor proxy.
        final int width = anchoredView.getWidth();
        final int height = anchoredView.getHeight();

        // Depending on which size of the screen the view is closer to, use gravity on that side,
        // and offset a little extra from that side.
        final boolean viewOnLeftHalf = screenPos[0] < screenWidth / 2;
        final int horizontalGravity = viewOnLeftHalf ? Gravity.LEFT : Gravity.RIGHT;
        final int xOffset =
                viewOnLeftHalf ? screenPos[0] + width / 2 : screenWidth - screenPos[0] - width / 2;
        // Similar to xOffset, only always use Gravity.TOP. This seems to usually cause overlap with
        // the anchor view on the bottom half of the screen.
        final boolean viewOnTopHalf = screenPos[1] < screenHeight / 2;
        final int yOffset =
                viewOnTopHalf ? screenPos[1] + height / 2 : screenPos[1] - height * 3 / 2;

        // There seems to be a host of issues that cause this to not be exactly correct. It's
        // unclear which measurements above are taking into account bezels and status/nav bars, and
        // which are not.
        setGravity(Gravity.TOP | horizontalGravity, xOffset, yOffset);
    }

    public void setMargin(float horizontalMargin, float verticalMargin) {
        mToast.setMargin(horizontalMargin, verticalMargin);
    }

    public float getHorizontalMargin() {
        return mToast.getHorizontalMargin();
    }

    public float getVerticalMargin() {
        return mToast.getVerticalMargin();
    }

    public void setGravity(int gravity, int xOffset, int yOffset) {
        mToast.setGravity(gravity, xOffset, yOffset);
    }

    public int getGravity() {
        return mToast.getGravity();
    }

    public int getXOffset() {
        return mToast.getXOffset();
    }

    public int getYOffset() {
        return mToast.getYOffset();
    }

    @SuppressLint("ShowToast")
    public static Toast makeText(Context context, CharSequence text, int duration) {
        return new Builder(context).withText(text).withDuration(duration).build();
    }

    public static Toast makeText(Context context, int resId, int duration) {
        return new Builder(context).withTextStringRes(resId).withDuration(duration).build();
    }

    /**
     * Create a new {@link Toast} with a given priority. See Javadoc for when to use it.
     * @param context {@link Context} in which the toast is created.
     * @param resId Resource of for the text message.
     * @param duration Duration of the toast. Either {@link android.widget.Toast#LENGTH_SHORT}
     *        or {@link android.widget.Toast#LENGTH_LONG}.
     * @param priority {@link ToastPriority} to be assigned to the toast.
     */
    public static Toast makeTextWithPriority(
            Context context, int resId, int duration, int priority) {
        return new Builder(context)
                .withTextStringRes(resId)
                .withDuration(duration)
                .withPriority(priority)
                .build();
    }

    /**
     * Shows a toast anchored on a view.
     * @param context The context to use for the toast.
     * @param anchoredView The view to anchor the toast.
     * @param description The string shown in the toast.
     * @return Whether a toast has been shown successfully.
     */
    public static boolean showAnchoredToast(
            Context context, View anchoredView, CharSequence description) {
        return new Builder(context)
                .withAnchoredView(anchoredView)
                .withText(description)
                .buildAndShow();
    }

    /** Builder pattern class to construct {@link Toast} with various arguments. */
    public static class Builder {
        private final Context mContext;
        private CharSequence mText;
        private View mAnchoredView;
        private Integer mBackgroundColor;
        private Integer mTextAppearance;
        private int mDuration = LENGTH_SHORT;
        private @ToastPriority int mPriority = ToastPriority.NORMAL;

        public Builder(Context context) {
            mContext = context;
        }

        public Builder withText(CharSequence text) {
            mText = text;
            return this;
        }

        public Builder withTextStringRes(@StringRes int textResId) {
            mText = mContext.getResources().getText(textResId);
            return this;
        }

        public Builder withAnchoredView(View anchoredView) {
            mAnchoredView = anchoredView;
            return this;
        }

        public Builder withBackgroundColor(@ColorInt int backgroundColor) {
            mBackgroundColor = backgroundColor;
            return this;
        }

        public Builder withTextAppearance(@StyleRes int textAppearance) {
            mTextAppearance = textAppearance;
            return this;
        }

        public Builder withDuration(int duration) {
            mDuration = duration;
            return this;
        }

        public Builder withPriority(@ToastPriority int priority) {
            mPriority = priority;
            return this;
        }

        private TextView inflateTextView() {
            LayoutInflater inflater = LayoutInflater.from(mContext);
            TextView textView = (TextView) inflater.inflate(R.layout.custom_toast_layout, null);
            if (mText != null) {
                textView.setText(mText);
                textView.announceForAccessibility(mText);
            }
            if (mBackgroundColor != null) {
                textView.getBackground().setTint(mBackgroundColor);
            }
            if (mTextAppearance != null) {
                textView.setTextAppearance(mTextAppearance);
            }
            return textView;
        }

        /** Builds and returns a toast, but does not show it. */
        public Toast build() {
            TextView textView = inflateTextView();
            Toast toast = new Toast(mContext, textView);
            if (mAnchoredView != null) {
                toast.anchor(mContext, mAnchoredView);
            }
            toast.setDuration(mDuration);
            toast.setPriority(mPriority);
            toast.setText(mText);
            return toast;
        }

        /** Builds and shows a toast, returning if it was successful. */
        public boolean buildAndShow() {
            if (mText == null) return false;
            build().show();
            return true;
        }
    }

    /**
     * Set extra Y offset for toasts all toasts created with this class. This can be overridden by
     * calling {@link Toast#setGravity(int, int, int)} on an individual toast.
     * @param yOffsetPx The Y offset from the normal toast position in px.
     */
    public static void setGlobalExtraYOffset(int yOffsetPx) {
        sExtraYOffset = yOffsetPx;
    }
}