chromium/chrome/browser/loading_modal/android/java/src/org/chromium/chrome/browser/loading_modal/LoadingModalDialogMediator.java

// Copyright 2022 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.loading_modal;

import android.os.Handler;
import android.os.SystemClock;

import org.chromium.base.ObserverList;
import org.chromium.base.supplier.Supplier;
import org.chromium.ui.modaldialog.DialogDismissalCause;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.modaldialog.ModalDialogManager.ModalDialogManagerObserver;
import org.chromium.ui.modaldialog.ModalDialogProperties;
import org.chromium.ui.modaldialog.ModalDialogProperties.ButtonType;
import org.chromium.ui.modelutil.PropertyModel;

/**
 * Mediator class responsible for handling button clicks and controlling the dialog state.
 *
 * Prevents UI flickering by following the hiding/showing policy defined in Material Design.
 */
class LoadingModalDialogMediator
        implements ModalDialogProperties.Controller, ModalDialogManagerObserver {
    private static final long SHOW_DELAY_TIME_MS = 500L;
    private static final long MINIMUM_SHOW_TIME_MS = 500L;
    // The load timeout limits the dialog visibility period, along with the show delay time it
    // results in 5000ms total loading timeout.
    private static final long LOAD_TIMEOUT_MS = 4500L;

    private final Handler mHandler;
    private final Supplier<ModalDialogManager> mDialogManagerSupplier;
    private final ObserverList<LoadingModalDialogCoordinator.Observer> mObservers =
            new ObserverList<>();

    private ModalDialogManager mDialogManager;
    private PropertyModel mModel;

    private long mShownAtMs;

    private @LoadingModalDialogCoordinator.State int mState;
    private boolean mSkipDelay;
    private boolean mDisableTimeout;

    /** ModalDialogProperties.Controller implementation */
    @Override
    public void onClick(PropertyModel model, @ButtonType int buttonType) {
        mState = LoadingModalDialogCoordinator.State.CANCELLED;
        dismissDialogWithCause(DialogDismissalCause.NEGATIVE_BUTTON_CLICKED);
    }

    @Override
    public void onDismiss(PropertyModel model, @DialogDismissalCause int dismissalCause) {
        mDialogManager.removeObserver(this);
        mHandler.removeCallbacksAndMessages(null);
        mState = getFinalStateByDismissalCause(dismissalCause);
        if (mState == LoadingModalDialogCoordinator.State.CANCELLED
                || mState == LoadingModalDialogCoordinator.State.TIMED_OUT) {
            for (LoadingModalDialogCoordinator.Observer observer : mObservers) {
                observer.onDismissedWithState(mState);
            }
        }
        mObservers.clear();
    }

    /**
     * ModalDialogManagerObserver implementation.
     *
     * Dialog could be postponed if there is a dialog with higher priority.
     * We track the actual time when the dialog become visible to keep it for a desired period of
     * time.
     *
     * Dialog manager calls {@link #onDismiss()} on dialog dismissal, no need to handle it here.
     */
    @Override
    public void onDialogAdded(PropertyModel model) {
        if (model != mModel) return;
        mState = LoadingModalDialogCoordinator.State.SHOWN;
        mShownAtMs = SystemClock.elapsedRealtime();
        postDelayed(this::onDismissDelayPassed, MINIMUM_SHOW_TIME_MS);
        if (!mDisableTimeout) postDelayed(this::onTimeoutOccurred, LOAD_TIMEOUT_MS);
    }

    LoadingModalDialogMediator(
            Supplier<ModalDialogManager> dialogManagerSupplier, Handler handler) {
        assert dialogManagerSupplier != null;
        assert handler != null;
        mDialogManagerSupplier = dialogManagerSupplier;
        mState = LoadingModalDialogCoordinator.State.READY;
        mHandler = handler;
    }

    /**
     * Add the listener that will be notified about the loading dialog cancellation and readiness to
     * be immediately dismissed.
     *
     * @param listener {@link LoadingModalDialogCoordinator.Observer} that will be notified.
     */
    void addObserver(LoadingModalDialogCoordinator.Observer listener) {
        mObservers.addObserver(listener);
    }

    /**
     * Schedules the dialog to be shown after {@link #SHOW_DELAY_TIME_MS} milliseconds.
     * The dialog will not be shown if {@link #dismiss()} called before it become visible.
     *
     * @param model The {@link PropertyModel} describing the dialog to be shown.
     *
     */
    void show(PropertyModel model) {
        assert mState == LoadingModalDialogCoordinator.State.READY;

        ModalDialogManager dialogManager = mDialogManagerSupplier.get();
        if (dialogManager == null) return;

        mDialogManager = dialogManager;
        mModel = model;
        mState = LoadingModalDialogCoordinator.State.PENDING;
        postDelayed(this::onShowDelayPassed, SHOW_DELAY_TIME_MS);
    }

    /**
     * Dismisses the currently visible dialog or cancelling the pending dialog if it is not visible
     * yet. If dialog is already visible for at least {@link #MINIMUM_SHOW_TIME_MS}, it will be
     * dismissed immediately. Otherwise it will be dismissed after being visible for that period of
     * time.
     */
    void dismiss() {
        if (mState == LoadingModalDialogCoordinator.State.PENDING) {
            mHandler.removeCallbacks(this::onShowDelayPassed);
        }

        mState = LoadingModalDialogCoordinator.State.FINISHED;
        if (isImmediatelyDismissable()) {
            dismissDialogWithCause(DialogDismissalCause.ACTION_ON_DIALOG_COMPLETED);
        }
    }

    /** Indicates the current dialog state. */
    @LoadingModalDialogCoordinator.State
    int getState() {
        return mState;
    }

    void skipDelays() {
        mSkipDelay = true;
    }

    void disableTimeout() {
        mDisableTimeout = true;
    }

    /** Indicates if the dailog could be immediately dismissed. */
    boolean isImmediatelyDismissable() {
        switch (mState) {
            case LoadingModalDialogCoordinator.State.PENDING:
            case LoadingModalDialogCoordinator.State.TIMED_OUT:
            case LoadingModalDialogCoordinator.State.CANCELLED:
                return true;
            case LoadingModalDialogCoordinator.State.SHOWN:
            case LoadingModalDialogCoordinator.State.FINISHED:
                return mSkipDelay
                        || SystemClock.elapsedRealtime() - mShownAtMs >= MINIMUM_SHOW_TIME_MS;
            case LoadingModalDialogCoordinator.State.READY:
                return false;
        }
        throw new AssertionError();
    }

    private void onShowDelayPassed() {
        if (mState == LoadingModalDialogCoordinator.State.PENDING) {
            showDialogImmediately();
        }
    }

    private void onDismissDelayPassed() {
        for (LoadingModalDialogCoordinator.Observer observer : mObservers) observer.onDismissable();
        if (mState == LoadingModalDialogCoordinator.State.FINISHED) {
            dismissDialogWithCause(DialogDismissalCause.ACTION_ON_DIALOG_COMPLETED);
        }
    }

    private void onTimeoutOccurred() {
        if (mState != LoadingModalDialogCoordinator.State.SHOWN) return;

        mState = LoadingModalDialogCoordinator.State.TIMED_OUT;
        dismissDialogWithCause(DialogDismissalCause.CLIENT_TIMEOUT);
    }

    /** Immediately shows the dialog. */
    private void showDialogImmediately() {
        assert mState == LoadingModalDialogCoordinator.State.PENDING;
        mDialogManager.addObserver(this);
        mDialogManager.showDialog(mModel, ModalDialogManager.ModalDialogType.TAB);
    }

    /**
     * Immediately dismisses the dialog with {@link DialogDismissalCause}.
     *
     * @param dismissalCause The {@link DialogDismissalCause} that describes why the dialog is
     *                       dismissed.
     */
    private void dismissDialogWithCause(@DialogDismissalCause int dismissalCause) {
        assert isImmediatelyDismissable();
        mDialogManager.dismissDialog(mModel, dismissalCause);
    }

    private void postDelayed(Runnable r, long delay) {
        if (mSkipDelay) {
            r.run();
        } else {
            mHandler.postDelayed(r, delay);
        }
    }

    private @LoadingModalDialogCoordinator.State int getFinalStateByDismissalCause(
            @DialogDismissalCause int dismissalCause) {
        switch (dismissalCause) {
            case DialogDismissalCause.ACTION_ON_DIALOG_COMPLETED:
                return LoadingModalDialogCoordinator.State.FINISHED;
            case DialogDismissalCause.CLIENT_TIMEOUT:
                return LoadingModalDialogCoordinator.State.TIMED_OUT;
            default:
                return LoadingModalDialogCoordinator.State.CANCELLED;
        }
    }
}