chromium/components/permissions/android/java/src/org/chromium/components/permissions/PermissionDialogController.java

// Copyright 2016 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.permissions;

import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.view.View;

import androidx.annotation.IntDef;

import org.jni_zero.CalledByNative;

import org.chromium.base.BuildInfo;
import org.chromium.base.ObserverList;
import org.chromium.components.content_settings.ContentSettingValues;
import org.chromium.components.content_settings.ContentSettingsType;
import org.chromium.ui.LayoutInflaterUtils;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.modaldialog.DialogDismissalCause;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.modaldialog.ModalDialogProperties;
import org.chromium.ui.modaldialog.SimpleModalDialogController;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.LinkedList;
import java.util.List;

/**
 * Singleton instance which controls the display of modal permission dialogs. This class is lazily
 * initiated when getInstance() is first called.
 *
 * Unlike permission infobars, which stack on top of each other, only one permission dialog may be
 * visible on the screen at once. Any additional request for a modal permissions dialog is queued,
 * and will be displayed once the user responds to the current dialog.
 */
public class PermissionDialogController
        implements AndroidPermissionRequester.RequestDelegate, ModalDialogProperties.Controller {
    @IntDef({
        State.NOT_SHOWING,
        State.PROMPT_OPEN,
        State.PROMPT_ACCEPTED,
        State.PROMPT_DENIED,
        State.REQUEST_ANDROID_PERMISSIONS_FOR_PERSISTENT_GRANT,
        State.REQUEST_ANDROID_PERMISSIONS_FOR_EPHEMERAL_GRANT,
        State.PROMPT_ACCEPTED_THIS_TIME
    })
    @Retention(RetentionPolicy.SOURCE)
    private @interface State {
        int NOT_SHOWING = 0;
        // We don't show prompts while Chrome Home is showing.
        // int PROMPT_PENDING = 1; // Obsolete.
        int PROMPT_OPEN = 2;
        int PROMPT_ACCEPTED = 3;
        int PROMPT_DENIED = 4;
        int REQUEST_ANDROID_PERMISSIONS_FOR_PERSISTENT_GRANT = 5;
        int REQUEST_ANDROID_PERMISSIONS_FOR_EPHEMERAL_GRANT = 6;
        int PROMPT_ACCEPTED_THIS_TIME = 7;
    }

    /** Interface for a class that wants to receive updates from this controller. */
    public interface Observer {
        /**
         * Notifies the observer that the user has just completed a permissions prompt.
         *
         * @param window The {@link WindowAndroid} for the prompt that just finished.
         * @param permissions An array of ContentSettingsType, indicating the last dialog
         *     permissions.
         * @param result A ContentSettingValues type, indicating the last dialog result.
         */
        void onDialogResult(
                WindowAndroid window,
                @ContentSettingsType.EnumType int[] permissions,
                @ContentSettingValues int result);
    }

    private final ObserverList<Observer> mObservers;

    private PropertyModel mDialogModel;
    private PropertyModel mCustomViewModel;
    private PropertyModelChangeProcessor mCustomViewModelChangeProcessor;
    private PropertyModel mOverlayDetectedDialogModel;
    private PermissionDialogDelegate mDialogDelegate;
    private ModalDialogManager mModalDialogManager;

    // As the PermissionRequestManager handles queueing for a tab and only shows prompts for active
    // tabs, we typically only have one request. This class only handles multiple requests at once
    // when either:
    // 1) Multiple open windows request permissions due to Android split-screen
    // 2) A tab navigates or is closed while the Android permission request is open, and the
    // subsequent page requests a permission
    private List<PermissionDialogDelegate> mRequestQueue;

    /** The current state, whether we have a prompt showing and so on. */
    private @State int mState;

    // Static holder to ensure safe initialization of the singleton instance.
    private static class Holder {
        @SuppressLint("StaticFieldLeak")
        private static final PermissionDialogController sInstance =
                new PermissionDialogController();
    }

    public static PermissionDialogController getInstance() {
        return Holder.sInstance;
    }

    private PermissionDialogController() {
        mRequestQueue = new LinkedList<>();
        mState = State.NOT_SHOWING;
        mObservers = new ObserverList<>();
    }

    /**
     * Called by native code to create a modal permission dialog. The PermissionDialogController
     * will decide whether the dialog needs to be queued (because another dialog is on the screen)
     * or whether it can be shown immediately.
     */
    @CalledByNative
    private static void createDialog(PermissionDialogDelegate delegate) {
        PermissionDialogController.getInstance().queueDialog(delegate);
    }

    /** @param observer An observer to be notified of changes. */
    public void addObserver(Observer observer) {
        mObservers.addObserver(observer);
    }

    /** @param observer The observer to remove. */
    public void removeObserver(Observer observer) {
        mObservers.removeObserver(observer);
    }

    /**
     * Queues a modal permission dialog for display. If there are currently no dialogs on screen, it
     * will be displayed immediately. Otherwise, it will be displayed as soon as the user responds
     * to the current dialog.
     * @param context  The context to use to get the dialog layout.
     * @param delegate The wrapper for the native-side permission delegate.
     */
    private void queueDialog(PermissionDialogDelegate delegate) {
        mRequestQueue.add(delegate);
        delegate.setDialogController(this);
        scheduleDisplay();
    }

    private void scheduleDisplay() {
        if (mState == State.NOT_SHOWING && !mRequestQueue.isEmpty()) dequeueDialog();
    }

    @Override
    public void onAndroidPermissionAccepted() {
        assert mState == State.REQUEST_ANDROID_PERMISSIONS_FOR_PERSISTENT_GRANT
                || mState == State.REQUEST_ANDROID_PERMISSIONS_FOR_EPHEMERAL_GRANT;

        // The tab may have navigated or been closed behind the Android permission prompt.
        if (mDialogDelegate == null) {
            mState = State.NOT_SHOWING;
        } else {
            if (mState == State.REQUEST_ANDROID_PERMISSIONS_FOR_PERSISTENT_GRANT) {
                mDialogDelegate.onAccept();
                destroyDelegate(ContentSettingValues.ALLOW);
            } else {
                // State.REQUEST_ANDROID_PERMISSIONS_FOR_EPHEMERAL_GRANT
                mDialogDelegate.onAcceptThisTime();
                destroyDelegate(ContentSettingValues.ALLOW);
            }
        }
        scheduleDisplay();
    }

    @Override
    public void onAndroidPermissionCanceled() {
        assert mState == State.REQUEST_ANDROID_PERMISSIONS_FOR_PERSISTENT_GRANT
                || mState == State.REQUEST_ANDROID_PERMISSIONS_FOR_EPHEMERAL_GRANT;

        // The tab may have navigated or been closed behind the Android permission prompt.
        if (mDialogDelegate == null) {
            mState = State.NOT_SHOWING;
        } else {
            // The user accepted the site-level prompt but denied the app-level prompt.
            // No content setting should be set.
            mDialogDelegate.onDismiss(DismissalType.AUTODISMISS_OS_DENIED);
            destroyDelegate(ContentSettingValues.DEFAULT);
        }
        scheduleDisplay();
    }

    /** Shows the dialog asking the user for a web API permission. */
    public void dequeueDialog() {
        assert mState == State.NOT_SHOWING;

        mDialogDelegate = mRequestQueue.remove(0);
        Context context = getContext();

        // It's possible for the context to be null if we reach here just after the user
        // backgrounds the browser and cleanup has happened. In that case, we can't show a prompt,
        // so act as though the user dismissed it.
        if (context == null) {
            // TODO(timloh): This probably doesn't work, as this happens synchronously when creating
            // the PermissionPromptAndroid, so the PermissionRequestManager won't be ready yet.
            mDialogDelegate.onDismiss(DismissalType.AUTODISMISS_NO_CONTEXT);
            destroyDelegate(ContentSettingValues.DEFAULT);
            return;
        }

        mModalDialogManager = mDialogDelegate.getWindow().getModalDialogManager();

        // The tab may have navigated or been closed while we were waiting for Chrome Home to close.
        // For some embedders (e.g. WebEngine) the layout might not be inflated and so the
        // ModalDialogManager is not available.
        if (mDialogDelegate == null || mModalDialogManager == null) {
            mState = State.NOT_SHOWING;
            scheduleDisplay();
            return;
        }

        // Setting up the MVC model for the dialog's custom view. One time prompts don't share the
        // same layout.
        View customView =
                mDialogDelegate.canShowEphemeralOption()
                        ? LayoutInflaterUtils.inflate(
                                context, R.layout.permission_dialog_one_time_permission, null)
                        : LayoutInflaterUtils.inflate(context, R.layout.permission_dialog, null);

        mCustomViewModel = PermissionDialogCustomViewModelFactory.getModel(mDialogDelegate);
        mCustomViewModelChangeProcessor =
                PropertyModelChangeProcessor.create(
                        mCustomViewModel,
                        customView,
                        mDialogDelegate.canShowEphemeralOption()
                                ? PermissionOneTimeDialogCustomViewBinder::bind
                                : PermissionDialogCustomViewBinder::bind);

        // Setting up the Permission's dialog.
        mDialogModel =
                PermissionDialogModelFactory.getModel(
                        this,
                        mDialogDelegate,
                        customView,
                        () -> showFilteredTouchEventDialog(context));

        mModalDialogManager.showDialog(mDialogModel, ModalDialogManager.ModalDialogType.TAB);
        mState = State.PROMPT_OPEN;
    }

    /**
     * Displays the dialog explaining that Chrome has detected an overlay. Offers the user to close
     * the overlay window and try again.
     */
    private void showFilteredTouchEventDialog(Context context) {
        // Don't show another dialog if one is already displayed.
        if (mOverlayDetectedDialogModel != null) return;

        ModalDialogProperties.Controller overlayDetectedDialogController =
                new SimpleModalDialogController(
                        mModalDialogManager,
                        (Integer dismissalCause) -> {
                            if (dismissalCause == DialogDismissalCause.POSITIVE_BUTTON_CLICKED
                                    && mDialogModel != null) {
                                mModalDialogManager.dismissDialog(
                                        mDialogModel,
                                        DialogDismissalCause.NAVIGATE_BACK_OR_TOUCH_OUTSIDE);
                            }
                            mOverlayDetectedDialogModel = null;
                        });
        mOverlayDetectedDialogModel =
                new PropertyModel.Builder(ModalDialogProperties.ALL_KEYS)
                        .with(ModalDialogProperties.CONTROLLER, overlayDetectedDialogController)
                        .with(
                                ModalDialogProperties.TITLE,
                                context.getString(
                                        R.string.overlay_detected_dialog_title,
                                        BuildInfo.getInstance().hostPackageLabel))
                        .with(
                                ModalDialogProperties.MESSAGE_PARAGRAPH_1,
                                context.getResources()
                                        .getString(R.string.overlay_detected_dialog_message))
                        .with(
                                ModalDialogProperties.POSITIVE_BUTTON_TEXT,
                                context.getResources(),
                                R.string.cancel)
                        .with(
                                ModalDialogProperties.NEGATIVE_BUTTON_TEXT,
                                context.getResources(),
                                R.string.try_again)
                        .with(ModalDialogProperties.CANCEL_ON_TOUCH_OUTSIDE, true)
                        .build();
        mModalDialogManager.showDialog(
                mOverlayDetectedDialogModel, ModalDialogManager.ModalDialogType.APP, true);
    }

    public void dismissFromNative(PermissionDialogDelegate delegate) {
        if (mDialogDelegate == delegate) {
            // Some caution is required here to handle cases where the user actions or dismisses
            // the prompt at roughly the same time as native. Due to asynchronicity, this function
            // may be called after onClick and before onDismiss, or before both of those listeners.
            mDialogDelegate = null;
            if (mState == State.PROMPT_OPEN) {
                mModalDialogManager.dismissDialog(
                        mDialogModel, DialogDismissalCause.DISMISSED_BY_NATIVE);
            } else {
                assert mState == State.REQUEST_ANDROID_PERMISSIONS_FOR_PERSISTENT_GRANT
                        || mState == State.REQUEST_ANDROID_PERMISSIONS_FOR_EPHEMERAL_GRANT
                        || mState == State.PROMPT_DENIED
                        || mState == State.PROMPT_ACCEPTED;
            }
        } else {
            assert mRequestQueue.contains(delegate);
            mRequestQueue.remove(delegate);
        }
        delegate.destroy();
    }

    public void updateIcon(Bitmap icon) {
        if (mCustomViewModel == null) {
            return;
        }

        mCustomViewModel.set(
                PermissionDialogCustomViewProperties.ICON,
                new BitmapDrawable(getContext().getResources(), icon));
        mCustomViewModel.set(PermissionDialogCustomViewProperties.ICON_TINT, null);
    }

    public int getIconSizeInPx() {
        return getContext().getResources().getDimensionPixelSize(R.dimen.large_favicon_size);
    }

    private Context getContext() {
        assert mDialogDelegate != null;
        // Use the context to access resources instead of the activity because the activity may not
        // have the correct resources in some cases (e.g. WebLayer).
        return mDialogDelegate.getWindow().getContext().get();
    }

    @Override
    public void onDismiss(PropertyModel model, @DialogDismissalCause int dismissalCause) {
        // Called when the dialog is dismissed. Interacting with any button in the dialog will
        // call this handler after its own handler.
        // When the dialog is dismissed, the delegate's native pointers are
        // freed, and the next queued dialog (if any) is displayed.
        mDialogModel = null;
        mCustomViewModel = null;
        mCustomViewModelChangeProcessor.destroy();
        if (mDialogDelegate == null) {
            // We get into here if a tab navigates or is closed underneath the
            // prompt.
            mState = State.NOT_SHOWING;
            return;
        }
        if (mState == State.PROMPT_ACCEPTED || mState == State.PROMPT_ACCEPTED_THIS_TIME) {
            if (mState == State.PROMPT_ACCEPTED) {
                mState = State.REQUEST_ANDROID_PERMISSIONS_FOR_PERSISTENT_GRANT;
            } else {
                mState = State.REQUEST_ANDROID_PERMISSIONS_FOR_EPHEMERAL_GRANT;
            }

            // Request Android permissions if necessary. This will call back into
            // either onAndroidPermissionAccepted or onAndroidPermissionCanceled,
            // which will schedule the next permission dialog. If it returns false,
            // no system level permissions need to be requested, so just run the
            // accept callback.
            if (!AndroidPermissionRequester.requestAndroidPermissions(
                    mDialogDelegate.getWindow(),
                    mDialogDelegate.getContentSettingsTypes(),
                    PermissionDialogController.this)) {
                onAndroidPermissionAccepted();
            }
        } else {
            // Otherwise, run the necessary delegate callback immediately and
            // schedule the next dialog.
            if (mState == State.PROMPT_DENIED) {
                mDialogDelegate.onCancel();
                destroyDelegate(ContentSettingValues.BLOCK);
            } else {
                assert mState == State.PROMPT_OPEN;

                @DismissalType int type = DismissalType.UNSPECIFIED;
                if (dismissalCause == DialogDismissalCause.NAVIGATE_BACK) {
                    type = DismissalType.NAVIGATE_BACK;
                } else if (dismissalCause == DialogDismissalCause.TOUCH_OUTSIDE) {
                    type = DismissalType.TOUCH_OUTSIDE;
                }
                mDialogDelegate.onDismiss(type);

                destroyDelegate(ContentSettingValues.DEFAULT);
            }
            scheduleDisplay();
        }
    }

    @Override
    public void onClick(PropertyModel model, @ModalDialogProperties.ButtonType int buttonType) {
        assert mState == State.PROMPT_OPEN;
        switch (buttonType) {
            case ModalDialogProperties.ButtonType.POSITIVE -> {
                mState = State.PROMPT_ACCEPTED;
                mModalDialogManager.dismissDialog(
                        model, DialogDismissalCause.POSITIVE_BUTTON_CLICKED);
            }
            case ModalDialogProperties.ButtonType.POSITIVE_EPHEMERAL -> {
                mState = State.PROMPT_ACCEPTED_THIS_TIME;
                mModalDialogManager.dismissDialog(
                        model, DialogDismissalCause.POSITIVE_BUTTON_CLICKED);
            }
            case ModalDialogProperties.ButtonType.NEGATIVE -> {
                mState = State.PROMPT_DENIED;
                mModalDialogManager.dismissDialog(
                        model, DialogDismissalCause.NEGATIVE_BUTTON_CLICKED);
            }
            default -> {
                assert false : "Unexpected button pressed in dialog: " + buttonType;
            }
        }
    }

    private void destroyDelegate(@ContentSettingValues int result) {
        if (result != ContentSettingValues.DEFAULT) {
            WindowAndroid currentWindow = mDialogDelegate.getWindow();
            for (Observer obs : mObservers) {
                obs.onDialogResult(
                        currentWindow, mDialogDelegate.getContentSettingsTypes().clone(), result);
            }
        }
        mDialogDelegate.destroy();
        mDialogDelegate = null;
        mState = State.NOT_SHOWING;
    }

    public boolean isDialogShownForTest() {
        return mDialogDelegate != null;
    }

    public void clickButtonForTest(@ModalDialogProperties.ButtonType int buttonType) {
        onClick(mDialogModel, buttonType);
    }
}