// 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.content.Context;
import android.content.pm.PackageManager;
import android.util.SparseArray;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;
import org.jni_zero.CalledByNative;
import org.chromium.base.BuildInfo;
import org.chromium.base.CollectionUtil;
import org.chromium.components.content_settings.ContentSettingsType;
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.modelutil.PropertyModel;
import org.chromium.ui.permissions.AndroidPermissionDelegate;
import org.chromium.ui.permissions.PermissionCallback;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
/**
* Methods to handle requesting native permissions from Android when the user grants a website a
* permission.
*/
public class AndroidPermissionRequester {
/**
* An interface for classes which need to be informed of the outcome of asking a user to grant
* an Android permission.
*/
public interface RequestDelegate {
void onAndroidPermissionAccepted();
void onAndroidPermissionCanceled();
}
private static Set<String> filterPermissionsKeepMissing(
AndroidPermissionDelegate permissionDelegate, String[] androidPermissions) {
Set<String> missingAndroidPermissions = new HashSet<String>();
for (String permission : androidPermissions) {
if (!permissionDelegate.hasPermission(permission)) {
missingAndroidPermissions.add(permission);
}
}
return missingAndroidPermissions;
}
private static int getContentSettingType(
SparseArray<Set<String>> contentSettingsTypesToPermissionsMap, String permission) {
// SparseArray#indexOfValue uses == instead of .equals, so we need to manually iterate
// over the list.
for (int i = 0; i < contentSettingsTypesToPermissionsMap.size(); i++) {
final Set<String> contentSettingPermissions =
contentSettingsTypesToPermissionsMap.valueAt(i);
if (contentSettingPermissions.contains(permission)) {
return contentSettingsTypesToPermissionsMap.keyAt(i);
}
}
return -1;
}
/**
* Determines whether the minimum required Android permissions are granted for the specified
* content setting.
*
* @param permissionDelegate The AndroidPermissionDelegate used to determine permission status.
* @param contentSettingsType The content setting whose permissions are being checked.
* @return Whether the necessary permissions are granted for the given content setting.
*/
@CalledByNative
public static boolean hasRequiredAndroidPermissionsForContentSetting(
AndroidPermissionDelegate permissionDelegate,
@ContentSettingsType.EnumType int contentSettingsType) {
Set<String> missingPermissions =
filterPermissionsKeepMissing(
permissionDelegate,
PermissionUtil.getRequiredAndroidPermissionsForContentSetting(
contentSettingsType));
// TODO(crbug.com/40765216): AndroidPermissionDelegate.hasPermission has side effects that
// allows users to recover from states where they had previously denied the permission, by
// virtue of clearing a Chrome-side shared preference instructing Chrome not to prompt again
// again. Ensure here that these prefs get cleared for optional permissions as well.
String[] optionalPermissions =
PermissionUtil.getOptionalAndroidPermissionsForContentSetting(contentSettingsType);
for (String permission : optionalPermissions) {
boolean unused_result = permissionDelegate.hasPermission(permission);
}
return missingPermissions.isEmpty();
}
/**
* Returns true if any of the permissions in contentSettingsTypes must be requested from the
* system. Otherwise returns false.
*
* If true is returned, this method will asynchronously request the necessary permissions using
* a dialog, running methods on the RequestDelegate when the user has made a decision.
*/
public static boolean requestAndroidPermissions(
final WindowAndroid windowAndroid,
final int[] contentSettingsTypes,
final RequestDelegate delegate) {
if (windowAndroid == null) return false;
SparseArray<Set<String>> contentSettingsTypesToRequiredPermissionsMap = new SparseArray<>();
Set<String> allPermissionsToRequest = new HashSet<>();
for (int contentSettingType : contentSettingsTypes) {
if (hasRequiredAndroidPermissionsForContentSetting(windowAndroid, contentSettingType)) {
continue;
}
final Set<String> requiredPermissions =
CollectionUtil.newHashSet(
PermissionUtil.getRequiredAndroidPermissionsForContentSetting(
contentSettingType));
final Set<String> optionalPermissions =
CollectionUtil.newHashSet(
PermissionUtil.getOptionalAndroidPermissionsForContentSetting(
contentSettingType));
contentSettingsTypesToRequiredPermissionsMap.append(
contentSettingType, requiredPermissions);
allPermissionsToRequest.addAll(requiredPermissions);
allPermissionsToRequest.addAll(optionalPermissions);
}
if (allPermissionsToRequest.isEmpty()) {
return false;
}
PermissionCallback callback =
new PermissionCallback() {
@Override
public void onRequestPermissionsResult(
String[] permissions, int[] grantResults) {
boolean allRequestable = true;
Set<Integer> deniedContentSettings = new HashSet<Integer>();
for (int i = 0; i < grantResults.length; i++) {
if (grantResults[i] == PackageManager.PERMISSION_DENIED) {
final int deniedContentSetting =
getContentSettingType(
contentSettingsTypesToRequiredPermissionsMap,
permissions[i]);
// Never mind if an optional Android permission was denied.
if (deniedContentSetting == -1) {
continue;
}
deniedContentSettings.add(deniedContentSetting);
if (!windowAndroid.canRequestPermission(permissions[i])) {
allRequestable = false;
}
}
}
Context context = windowAndroid.getContext().get();
if (allRequestable && !deniedContentSettings.isEmpty() && context != null) {
int deniedStringId = -1;
if (deniedContentSettings.size() == 2
&& deniedContentSettings.contains(
ContentSettingsType.MEDIASTREAM_MIC)
&& deniedContentSettings.contains(
ContentSettingsType.MEDIASTREAM_CAMERA)) {
deniedStringId =
R.string.infobar_missing_microphone_camera_permissions_text;
} else if (deniedContentSettings.size() == 1) {
if (deniedContentSettings.contains(
ContentSettingsType.GEOLOCATION)) {
deniedStringId =
R.string.infobar_missing_location_permission_text;
} else if (deniedContentSettings.contains(
ContentSettingsType.MEDIASTREAM_MIC)) {
deniedStringId =
R.string.infobar_missing_microphone_permission_text;
} else if (deniedContentSettings.contains(
ContentSettingsType.MEDIASTREAM_CAMERA)) {
deniedStringId =
R.string.infobar_missing_camera_permission_text;
} else if (deniedContentSettings.contains(
ContentSettingsType.HAND_TRACKING)) {
deniedStringId =
R.string.infobar_missing_hand_tracking_permission_text;
} else if (deniedContentSettings.contains(ContentSettingsType.AR)) {
deniedStringId =
R.string.infobar_missing_ar_camera_permission_text;
} else if (deniedContentSettings.contains(
ContentSettingsType.NOTIFICATIONS)) {
// We don't want to request the notification prompt again, since
// user declined it already.
delegate.onAndroidPermissionCanceled();
return;
}
}
assert deniedStringId != -1
: "Invalid combination of missing content settings: "
+ deniedContentSettings;
String appName = BuildInfo.getInstance().hostPackageLabel;
showMissingPermissionDialog(
windowAndroid,
context.getString(deniedStringId, appName),
(model) -> {
final ModalDialogManager modalDialogManager =
windowAndroid.getModalDialogManager();
modalDialogManager.dismissDialog(
model,
DialogDismissalCause.POSITIVE_BUTTON_CLICKED);
requestAndroidPermissions(
windowAndroid, contentSettingsTypes, delegate);
},
delegate::onAndroidPermissionCanceled);
} else if (deniedContentSettings.isEmpty()) {
delegate.onAndroidPermissionAccepted();
} else {
delegate.onAndroidPermissionCanceled();
}
}
};
windowAndroid.requestPermissions(
allPermissionsToRequest.toArray(new String[allPermissionsToRequest.size()]),
callback);
return true;
}
/**
* Shows a dialog that informs the user about a missing Android permission. Note that
* the dialog is not dismissed when the positive button is clicked, rather it will be
* dismissed after the Android permissions dialog is dismissed.
* @param windowAndroid Current WindowAndroid.
* @param messageId The message that is shown on the dialog.
* @param onPositiveButtonClicked Consumer that is executed on positive button click.
* It takes a PropertyModel.
* @param onCancelled Runnable that is executed on cancellation.
*/
public static void showMissingPermissionDialog(
WindowAndroid windowAndroid,
String message,
Consumer<PropertyModel> onPositiveButtonClicked,
Runnable onCancelled) {
final ModalDialogManager modalDialogManager = windowAndroid.getModalDialogManager();
assert modalDialogManager != null : "ModalDialogManager is null";
ModalDialogProperties.Controller controller =
new ModalDialogProperties.Controller() {
@Override
public void onClick(PropertyModel model, int buttonType) {
if (buttonType == ModalDialogProperties.ButtonType.POSITIVE) {
onPositiveButtonClicked.accept(model);
}
}
@Override
public void onDismiss(PropertyModel model, int dismissalCause) {
if (dismissalCause != DialogDismissalCause.POSITIVE_BUTTON_CLICKED) {
onCancelled.run();
}
}
};
Context context = windowAndroid.getContext().get();
View view = LayoutInflater.from(context).inflate(R.layout.update_permissions_dialog, null);
TextView dialogText = view.findViewById(R.id.text);
dialogText.setText(message);
PropertyModel dialogModel =
new PropertyModel.Builder(ModalDialogProperties.ALL_KEYS)
.with(ModalDialogProperties.CUSTOM_VIEW, view)
.with(ModalDialogProperties.CANCEL_ON_TOUCH_OUTSIDE, true)
.with(
ModalDialogProperties.POSITIVE_BUTTON_TEXT,
context.getString(R.string.infobar_update_permissions_button_text))
.with(ModalDialogProperties.CONTROLLER, controller)
.build();
modalDialogManager.showDialog(dialogModel, ModalDialogManager.ModalDialogType.APP);
}
}