| // Copyright 2016 The Chromium Authors. All rights reserved. |
| // 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.permissions; |
| |
| import android.annotation.SuppressLint; |
| |
| import androidx.annotation.IntDef; |
| |
| import org.chromium.base.VisibleForTesting; |
| import org.chromium.base.annotations.CalledByNative; |
| import org.chromium.chrome.browser.ChromeActivity; |
| import org.chromium.chrome.browser.widget.bottomsheet.BottomSheet; |
| import org.chromium.chrome.browser.widget.bottomsheet.EmptyBottomSheetObserver; |
| import org.chromium.ui.modaldialog.DialogDismissalCause; |
| import org.chromium.ui.modaldialog.ModalDialogManager; |
| import org.chromium.ui.modaldialog.ModalDialogProperties; |
| import org.chromium.ui.modelutil.PropertyModel; |
| |
| 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_PENDING, State.PROMPT_OPEN, State.PROMPT_ACCEPTED, |
| State.PROMPT_DENIED, State.REQUEST_ANDROID_PERMISSIONS}) |
| @Retention(RetentionPolicy.SOURCE) |
| private @interface State { |
| int NOT_SHOWING = 0; |
| // We don't show prompts while Chrome Home is showing. |
| int PROMPT_PENDING = 1; |
| int PROMPT_OPEN = 2; |
| int PROMPT_ACCEPTED = 3; |
| int PROMPT_DENIED = 4; |
| int REQUEST_ANDROID_PERMISSIONS = 5; |
| } |
| |
| private PropertyModel mDialogModel; |
| 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; |
| } |
| |
| /** |
| * 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); |
| } |
| |
| /** |
| * 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; |
| |
| // The tab may have navigated or been closed behind the Android permission prompt. |
| if (mDialogDelegate == null) { |
| mState = State.NOT_SHOWING; |
| } else { |
| mDialogDelegate.onAccept(); |
| destroyDelegate(); |
| } |
| scheduleDisplay(); |
| } |
| |
| @Override |
| public void onAndroidPermissionCanceled() { |
| assert mState == State.REQUEST_ANDROID_PERMISSIONS; |
| |
| // The tab may have navigated or been closed behind the Android permission prompt. |
| if (mDialogDelegate == null) { |
| mState = State.NOT_SHOWING; |
| } else { |
| mDialogDelegate.onDismiss(); |
| destroyDelegate(); |
| } |
| scheduleDisplay(); |
| } |
| |
| /** |
| * Shows the dialog asking the user for a web API permission. |
| */ |
| public void dequeueDialog() { |
| assert mState == State.NOT_SHOWING; |
| |
| mDialogDelegate = mRequestQueue.remove(0); |
| mState = State.PROMPT_PENDING; |
| ChromeActivity activity = mDialogDelegate.getTab().getActivity(); |
| |
| // It's possible for the activity 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 (activity == 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(); |
| destroyDelegate(); |
| return; |
| } |
| |
| // Suppress modals while Chrome Home is open. Eventually we will want to handle other cases |
| // whereby the tab is obscured so modals don't pop up on top of (e.g.) the tab switcher or |
| // the three-dot menu. |
| final BottomSheet bottomSheet = activity.getBottomSheet(); |
| if (bottomSheet == null || !bottomSheet.isSheetOpen()) { |
| showDialog(); |
| } else { |
| bottomSheet.addObserver(new EmptyBottomSheetObserver() { |
| @Override |
| public void onSheetClosed(int reason) { |
| bottomSheet.removeObserver(this); |
| if (reason == BottomSheet.StateChangeReason.NAVIGATION) { |
| // Dismiss the prompt as it would otherwise be dismissed momentarily once |
| // the navigation completes. |
| // TODO(timloh): This logs a dismiss (and we also already logged a show), |
| // even though the user didn't see anything. |
| mDialogDelegate.onDismiss(); |
| destroyDelegate(); |
| } else { |
| showDialog(); |
| } |
| } |
| }); |
| } |
| } |
| |
| private void showDialog() { |
| assert mState == State.PROMPT_PENDING; |
| |
| // The tab may have navigated or been closed while we were waiting for Chrome Home to close. |
| if (mDialogDelegate == null) { |
| mState = State.NOT_SHOWING; |
| scheduleDisplay(); |
| return; |
| } |
| |
| mModalDialogManager = mDialogDelegate.getTab().getActivity().getModalDialogManager(); |
| mDialogModel = PermissionDialogModel.getModel(this, mDialogDelegate); |
| mModalDialogManager.showDialog(mDialogModel, ModalDialogManager.ModalDialogType.TAB); |
| mState = State.PROMPT_OPEN; |
| } |
| |
| 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.PROMPT_PENDING || mState == State.REQUEST_ANDROID_PERMISSIONS |
| || mState == State.PROMPT_DENIED || mState == State.PROMPT_ACCEPTED; |
| } |
| } else { |
| assert mRequestQueue.contains(delegate); |
| mRequestQueue.remove(delegate); |
| } |
| delegate.destroy(); |
| } |
| |
| @Override |
| public void onDismiss(PropertyModel model, @DialogDismissalCause int dismissalCause) { |
| // Called when the dialog is dismissed. Interacting with either button in the dialog will |
| // call this handler after the primary/secondary handler. |
| // When the dialog is dismissed, the delegate's native pointers are |
| // freed, and the next queued dialog (if any) is displayed. |
| mDialogModel = null; |
| 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) { |
| // 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. |
| mState = State.REQUEST_ANDROID_PERMISSIONS; |
| if (!AndroidPermissionRequester.requestAndroidPermissions(mDialogDelegate.getTab(), |
| 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(); |
| } else { |
| assert mState == State.PROMPT_OPEN; |
| mDialogDelegate.onDismiss(); |
| } |
| destroyDelegate(); |
| 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); |
| break; |
| case ModalDialogProperties.ButtonType.NEGATIVE: |
| mState = State.PROMPT_DENIED; |
| mModalDialogManager.dismissDialog( |
| model, DialogDismissalCause.NEGATIVE_BUTTON_CLICKED); |
| break; |
| default: |
| assert false : "Unexpected button pressed in dialog: " + buttonType; |
| } |
| } |
| |
| private void destroyDelegate() { |
| mDialogDelegate.destroy(); |
| mDialogDelegate = null; |
| mState = State.NOT_SHOWING; |
| } |
| |
| @VisibleForTesting |
| public boolean isDialogShownForTest() { |
| return mDialogDelegate != null; |
| } |
| |
| @VisibleForTesting |
| public void clickButtonForTest(@ModalDialogProperties.ButtonType int buttonType) { |
| onClick(mDialogModel, buttonType); |
| } |
| } |