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

// Copyright 2023 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 android.os.Build;
import android.os.Handler;
import android.text.TextUtils;

import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;

import org.jni_zero.JNINamespace;

import java.util.Iterator;
import java.util.PriorityQueue;

/**
 * Manages Android toasts based on their priorities.
 * <ul>
 * <li>Queues the requested toasts and shows them one by one in the order of the requested
 *     time if they have the same priority.</li>
 * <li>Shows the toast with high priority ahead of other queued normal priority ones.</li>
 * <li>Does not show the requested one again if it is already in the queue or currently
 *     showing. Toasts of same text content are regarded as duplicated.</li>
 * </ul>
 */
@JNINamespace("ui")
public class ToastManager {
    private static final int DURATION_SHORT_MS = 2000;
    private static final int DURATION_LONG_MS = 3500;

    private static ToastManager sInstance;

    // A queue for toasts waiting to be shown.
    private final PriorityQueue<Toast> mToastQueue =
            new PriorityQueue<>((toast1, toast2) -> toast1.getPriority() - toast2.getPriority());

    // Handles toast events per SDK version.
    private interface ToastEvent {
        void onShow(Toast toast);

        void onCancel();
    }

    private final ToastEvent mToastEvent;

    // Toast currently showing. {@code null} if none is showing.
    private Toast mToast;

    static ToastManager getInstance() {
        if (sInstance == null) sInstance = new ToastManager();
        return sInstance;
    }

    private ToastManager() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
            mToastEvent = new ToastEventPreR(this::showNextToast);
        } else {
            mToastEvent = new ToastEventR(this::showNextToast);
        }
    }

    /**
     * Request to show a toast.
     * @param toast {@link Toast} object to show.
     */
    public void requestShow(Toast toast) {
        if (toast == null || isDuplicatedToast(toast)) return;

        mToastQueue.add(toast);

        if (getCurrentToast() == null) showNextToast();
    }

    /**
     * Cancel a toast if it is showing now, or removes it from the queue if found in it.
     * @param toast {@link Toast} to cancel.
     */
    public void cancel(Toast toast) {
        if (toast == getCurrentToast()) {
            cancelAndShowNextToast();
        } else {
            Iterator it = mToastQueue.iterator();
            Toast toastToRemove = null;
            while (it.hasNext()) {
                Toast t = (Toast) it.next();
                if (TextUtils.equals(t.getText(), toast.getText())) {
                    toastToRemove = t;
                    break;
                }
            }
            if (toastToRemove != null) mToastQueue.remove(toastToRemove);
        }
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    Toast getCurrentToast() {
        return mToast;
    }

    /** Check if we already have the same Toast object showing on the screen or in the queue. */
    private boolean isDuplicatedToast(Toast toast) {
        assert toast != null;
        Toast ct = getCurrentToast();
        if (ct != null && (ct == toast || TextUtils.equals(ct.getText(), toast.getText()))) {
            return true;
        }

        CharSequence text = toast.getText();
        Iterator it = mToastQueue.iterator();
        while (it.hasNext()) {
            Toast t = (Toast) it.next();
            if (t == toast || TextUtils.equals(t.getText(), toast.getText())) {
                return true;
            }
        }
        return false;
    }

    private void showNextToast() {
        mToast = mToastQueue.poll(); // Retrieves and removes head of the queue.
        if (mToast != null) {
            mToast.getAndroidToast().show();
            mToastEvent.onShow(mToast);
        }
    }

    private void cancelAndShowNextToast() {
        assert mToast != null : "Current toast cannot be null";
        mToast.getAndroidToast().cancel();
        mToast = null;
        mToastEvent.onCancel();
    }

    private class ToastEventPreR implements ToastEvent {
        private final Handler mHandler = new Handler();
        private final Runnable mPostToastRunnable;

        ToastEventPreR(Runnable finishRunnable) {
            mPostToastRunnable = finishRunnable;
        }

        @Override
        public void onShow(Toast toast) {
            int durationMs =
                    (mToast.getDuration() == Toast.LENGTH_SHORT)
                            ? DURATION_SHORT_MS
                            : DURATION_LONG_MS;
            mHandler.postDelayed(mPostToastRunnable, durationMs);
        }

        @Override
        public void onCancel() {
            mHandler.removeCallbacks(mPostToastRunnable);
            mPostToastRunnable.run();
        }
    }

    @RequiresApi(Build.VERSION_CODES.R)
    private class ToastEventR implements ToastEvent {
        private final android.widget.Toast.Callback mToastCallback;

        ToastEventR(Runnable finishRunnable) {
            mToastCallback =
                    new android.widget.Toast.Callback() {
                        @Override
                        public void onToastHidden() {
                            finishRunnable.run();
                        }
                    };
        }

        @Override
        public void onShow(Toast toast) {
            toast.getAndroidToast().addCallback(mToastCallback);
        }

        @Override
        public void onCancel() {
            // On R+, Callback#onToastHidden handles |showNextToast| when canceled.
        }
    }

    /**
     * Resets ToastManager state to initial state. Cancels the current toast if present,
     * and clears the queue. This prevernts a test running a toast from interfering another one.
     */
    public static void resetForTesting() {
        getInstance().resetInternalForTesting(); // IN-TEST
    }

    private void resetInternalForTesting() {
        mToastQueue.clear();
        if (mToast != null) cancel(mToast);
    }

    boolean isShowingForTesting() {
        return mToast != null;
    }
}