// Copyright 2019 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.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.Window;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.ProgressBar;
import androidx.annotation.VisibleForTesting;
import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;
import org.chromium.base.MathUtils;
import org.chromium.components.omnibox.AutocompleteSchemeClassifier;
import org.chromium.components.omnibox.OmniboxUrlEmphasizer;
import org.chromium.content_public.browser.bluetooth_scanning.Event;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.util.ColorUtils;
import org.chromium.ui.widget.TextViewWithClickableSpans;
/**
* A dialog for asking user permission to do Bluetooth scanning. This dialog is shown when a
* website requests to scan nearby Bluetooth devices (e.g. through a bluetooth.requestLEScan
* Javascript call).
*
* The dialog is shown by create(), and always runs finishDialog() as it's closing.
*/
@JNINamespace("permissions")
public class BluetoothScanningPermissionDialog {
// How much of the height of the screen should be taken up by the listview.
private static final float LISTVIEW_HEIGHT_PERCENT = 0.30f;
// The height of a row of the listview in dp.
private static final int LIST_ROW_HEIGHT_DP = 48;
// The minimum height of the listview in the dialog (in dp).
private static final int MIN_HEIGHT_DP = (int) (LIST_ROW_HEIGHT_DP * 1.5);
// The maximum height of the listview in the dialog (in dp).
private static final int MAX_HEIGHT_DP = (int) (LIST_ROW_HEIGHT_DP * 8.5);
// The window that owns this dialog.
private final WindowAndroid mWindowAndroid;
// Always equal to mWindowAndroid.getActivity().get(), but stored separately to make sure it's
// not GC'ed.
private final Activity mActivity;
// Always equal to mWindowAndroid.getContext().get(), but stored separately to make sure it's
// not GC'ed.
private final Context mContext;
// The dialog this class encapsulates.
private Dialog mDialog;
// Individual UI elements.
private final ListView mListView;
// The adapter containing the items to show in the dialog.
private final DeviceItemAdapter mItemAdapter;
// If this variable is false, the window should be closed when it loses focus;
// Otherwise, the window should not be closed when it loses focus.
private boolean mIgnorePendingWindowFocusChangeForClose;
// The embedder-provided delegate.
private final BluetoothScanningPromptAndroidDelegate mDelegate;
// A pointer back to the native part of the implementation for this dialog.
private long mNativeBluetoothScanningPermissionDialogPtr;
/**
* Creates the BluetoothScanningPermissionDialog.
*
* @param windowAndroid The window that owns this dialog.
* @param origin The origin for the site wanting to do Bluetooth scanning.
* @param securityLevel The security level of the connection to the site wanting to do
* Bluetooth scanning. For valid values see
* SecurityStateModel::SecurityLevel.
* @param nativeBluetoothScanningPermissionDialogPtr A pointer back to the native part of the
* implementation for this dialog.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public BluetoothScanningPermissionDialog(
WindowAndroid windowAndroid,
String origin,
int securityLevel,
BluetoothScanningPromptAndroidDelegate delegate,
long nativeBluetoothScanningPermissionDialogPtr) {
mWindowAndroid = windowAndroid;
mActivity = windowAndroid.getActivity().get();
assert mActivity != null;
mContext = windowAndroid.getContext().get();
assert mContext != null;
mDelegate = delegate;
mNativeBluetoothScanningPermissionDialogPtr = nativeBluetoothScanningPermissionDialogPtr;
// Emphasize the origin.
SpannableString originSpannableString = new SpannableString(origin);
final boolean useDarkColors = !ColorUtils.inNightMode(mContext);
AutocompleteSchemeClassifier autocompleteSchemeClassifier =
mDelegate.createAutocompleteSchemeClassifier();
OmniboxUrlEmphasizer.emphasizeUrl(
originSpannableString,
mContext,
autocompleteSchemeClassifier,
securityLevel,
useDarkColors,
/* emphasizeScheme= */ true);
autocompleteSchemeClassifier.destroy();
// Construct a full string and replace the |originSpannableString| text with emphasized
// version.
SpannableString title =
new SpannableString(
mContext.getString(R.string.bluetooth_scanning_prompt_origin, origin));
int start = title.toString().indexOf(origin);
TextUtils.copySpansFrom(
originSpannableString,
0,
originSpannableString.length(),
Object.class,
title,
start);
String noneFound =
mContext.getString(R.string.bluetooth_scanning_prompt_no_devices_found_prompt);
String blockButtonText =
mContext.getString(R.string.bluetooth_scanning_prompt_block_button_text);
String allowButtonText =
mContext.getString(R.string.bluetooth_scanning_prompt_allow_button_text);
LinearLayout dialogContainer =
(LinearLayout)
LayoutInflater.from(mContext)
.inflate(R.layout.bluetooth_scanning_permission_dialog, null);
TextViewWithClickableSpans dialogTitle =
(TextViewWithClickableSpans) dialogContainer.findViewById(R.id.dialog_title);
dialogTitle.setText(title);
dialogTitle.setMovementMethod(LinkMovementMethod.getInstance());
TextViewWithClickableSpans emptyMessage =
(TextViewWithClickableSpans) dialogContainer.findViewById(R.id.not_found_message);
emptyMessage.setText(noneFound);
emptyMessage.setMovementMethod(LinkMovementMethod.getInstance());
emptyMessage.setVisibility(View.VISIBLE);
mListView = (ListView) dialogContainer.findViewById(R.id.items);
mItemAdapter =
new DeviceItemAdapter(
mContext,
/* itemsSelectable= */ false,
R.layout.bluetooth_scanning_permission_dialog_row);
mItemAdapter.setNotifyOnChange(true);
mListView.setAdapter(mItemAdapter);
mListView.setEmptyView(emptyMessage);
mListView.setDivider(null);
ProgressBar progressBar = (ProgressBar) dialogContainer.findViewById(R.id.progress);
progressBar.setVisibility(View.GONE);
Button blockButton = (Button) dialogContainer.findViewById(R.id.block);
blockButton.setText(blockButtonText);
blockButton.setEnabled(true);
blockButton.setOnClickListener(
v -> {
finishDialog(Event.BLOCK);
mDialog.setOnDismissListener(null);
mDialog.dismiss();
});
Button allowButton = (Button) dialogContainer.findViewById(R.id.allow);
allowButton.setText(allowButtonText);
allowButton.setEnabled(true);
allowButton.setOnClickListener(
v -> {
finishDialog(Event.ALLOW);
mDialog.setOnDismissListener(null);
mDialog.dismiss();
});
mIgnorePendingWindowFocusChangeForClose = false;
showDialogForView(dialogContainer);
dialogContainer.addOnLayoutChangeListener(
(View v, int l, int t, int r, int b, int ol, int ot, int or, int ob) -> {
if (l != ol || t != ot || r != or || b != ob) {
// The list is the main element in the dialog and it should grow and
// shrink according to the size of the screen available.
View listViewContainer = dialogContainer.findViewById(R.id.container);
listViewContainer.setLayoutParams(
new LinearLayout.LayoutParams(
LayoutParams.MATCH_PARENT,
getListHeight(
mActivity.getWindow().getDecorView().getHeight(),
mContext.getResources()
.getDisplayMetrics()
.density)));
}
});
}
@CalledByNative
private static BluetoothScanningPermissionDialog create(
WindowAndroid windowAndroid,
String origin,
int securityLevel,
BluetoothScanningPromptAndroidDelegate delegate,
long nativeBluetoothScanningPermissionDialogPtr) {
BluetoothScanningPermissionDialog dialog =
new BluetoothScanningPermissionDialog(
windowAndroid,
origin,
securityLevel,
delegate,
nativeBluetoothScanningPermissionDialogPtr);
return dialog;
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@CalledByNative
public void addOrUpdateDevice(String deviceId, String deviceName) {
if (TextUtils.isEmpty(deviceName)) {
deviceName = mContext.getString(R.string.bluetooth_scanning_device_unknown, deviceId);
}
mItemAdapter.addOrUpdate(
deviceId, deviceName, /* icon= */ null, /* iconDescription= */ null);
mListView.setVisibility(View.VISIBLE);
}
@CalledByNative
private void closeDialog() {
mNativeBluetoothScanningPermissionDialogPtr = 0;
mDialog.dismiss();
}
// Computes the height of the device list, bound to half-multiples of the
// row height so that it's obvious if there are more elements to scroll to.
static int getListHeight(int decorHeight, float density) {
float heightDp = decorHeight / density * LISTVIEW_HEIGHT_PERCENT;
// Round to (an integer + 0.5) times LIST_ROW_HEIGHT.
heightDp = (Math.round(heightDp / LIST_ROW_HEIGHT_DP - 0.5f) + 0.5f) * LIST_ROW_HEIGHT_DP;
heightDp = MathUtils.clamp(heightDp, MIN_HEIGHT_DP, MAX_HEIGHT_DP);
return Math.round(heightDp * density);
}
private void showDialogForView(View view) {
mDialog =
new Dialog(mContext) {
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (!mIgnorePendingWindowFocusChangeForClose && !hasFocus) super.dismiss();
mIgnorePendingWindowFocusChangeForClose = false;
}
};
mDialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
mDialog.setCanceledOnTouchOutside(true);
mDialog.addContentView(
view,
new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.MATCH_PARENT));
mDialog.setOnCancelListener(dialog -> finishDialog(Event.CANCELED));
Window window = mDialog.getWindow();
if (!DeviceFormFactor.isNonMultiDisplayContextOnTablet(mContext)) {
// On smaller screens, make the dialog fill the width of the screen,
// and appear at the top.
window.setBackgroundDrawable(new ColorDrawable(Color.WHITE));
window.setGravity(Gravity.TOP);
window.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}
mDialog.show();
}
// Called to report the permission dialog's results back to native code.
private void finishDialog(int resultCode) {
if (mNativeBluetoothScanningPermissionDialogPtr == 0) return;
Natives jni = BluetoothScanningPermissionDialogJni.get();
jni.onDialogFinished(mNativeBluetoothScanningPermissionDialogPtr, resultCode);
}
/** Returns the dialog associated with this class. For use with tests only. */
public Dialog getDialogForTesting() {
return mDialog;
}
/** Returns the ItemAdapter associated with this class. For use with tests only. */
public DeviceItemAdapter getItemAdapterForTesting() {
return mItemAdapter;
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
@NativeMethods
public interface Natives {
void onDialogFinished(long nativeBluetoothScanningPromptAndroid, int eventType);
}
}