blob: 6cc029647c4fcb8d375cd5a6109d97ffcfc62e8e [file] [log] [blame]
// Copyright 2015 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.components.permissions;
import android.Manifest;
import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.location.LocationManager;
import android.text.SpannableString;
import android.text.TextUtils;
import android.view.View;
import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
import org.chromium.base.BuildInfo;
import org.chromium.base.Log;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.JNINamespace;
import org.chromium.base.annotations.NativeMethods;
import org.chromium.components.location.LocationUtils;
import org.chromium.components.omnibox.AutocompleteSchemeClassifier;
import org.chromium.components.omnibox.OmniboxUrlEmphasizer;
import org.chromium.content_public.browser.bluetooth.BluetoothChooserEvent;
import org.chromium.ui.base.PermissionCallback;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.text.NoUnderlineClickableSpan;
import org.chromium.ui.text.SpanApplier;
import org.chromium.ui.text.SpanApplier.SpanInfo;
import org.chromium.ui.util.ColorUtils;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* A dialog for picking available Bluetooth devices. This dialog is shown when a website requests to
* pair with a certain class of Bluetooth devices (e.g. through a bluetooth.requestDevice Javascript
* call).
*
* The dialog is shown by create() or show(), and always runs finishDialog() as it's closing.
*/
@JNINamespace("permissions")
public class BluetoothChooserDialog
implements ItemChooserDialog.ItemSelectedCallback, PermissionCallback {
private static final String TAG = "Bluetooth";
// These constants match BluetoothChooserAndroid::ShowDiscoveryState, and are used in
// notifyDiscoveryState().
@IntDef({DiscoveryMode.DISCOVERY_FAILED_TO_START, DiscoveryMode.DISCOVERING,
DiscoveryMode.DISCOVERY_IDLE})
@Retention(RetentionPolicy.SOURCE)
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public @interface DiscoveryMode {
int DISCOVERY_FAILED_TO_START = 0;
int DISCOVERING = 1;
int DISCOVERY_IDLE = 2;
}
// The window that owns this dialog.
final WindowAndroid mWindowAndroid;
// Always equal to mWindowAndroid.getActivity().get(), but stored separately to make sure it's
// not GC'ed.
final Activity mActivity;
// Always equal to mWindowAndroid.getContext().get(), but stored separately to make sure it's
// not GC'ed.
final Context mContext;
// The dialog to show to let the user pick a device.
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public ItemChooserDialog mItemChooserDialog;
// The origin for the site wanting to pair with the bluetooth devices.
final String mOrigin;
// The security level of the connection to the site wanting to pair with the
// bluetooth devices. For valid values see SecurityStateModel::SecurityLevel.
final int mSecurityLevel;
// The embedder-provided delegate.
final BluetoothChooserAndroidDelegate mDelegate;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public Drawable mConnectedIcon;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public String mConnectedIconDescription;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public Drawable[] mSignalStrengthLevelIcon;
// A pointer back to the native part of the implementation for this dialog.
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public long mNativeBluetoothChooserDialogPtr;
// Used to keep track of when the Mode Changed Receiver is registered.
boolean mIsLocationModeChangedReceiverRegistered;
// The local device Bluetooth adapter.
private final BluetoothAdapter mAdapter;
// The status message to show when the bluetooth adapter is turned off.
private final SpannableString mAdapterOffStatus;
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public final BroadcastReceiver mLocationModeBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (!LocationManager.MODE_CHANGED_ACTION.equals(intent.getAction())) {
return;
}
if (checkLocationServicesAndPermission()) {
mItemChooserDialog.clear();
Natives jni = BluetoothChooserDialogJni.get();
jni.restartSearch(mNativeBluetoothChooserDialogPtr);
}
}
};
// The type of link that is shown within the dialog.
@IntDef({LinkType.EXPLAIN_BLUETOOTH, LinkType.ADAPTER_OFF, LinkType.ADAPTER_OFF_HELP,
LinkType.REQUEST_PERMISSIONS, LinkType.REQUEST_LOCATION_SERVICES,
LinkType.NEED_LOCATION_PERMISSION_HELP, LinkType.RESTART_SEARCH})
@Retention(RetentionPolicy.SOURCE)
public @interface LinkType {
int EXPLAIN_BLUETOOTH = 0;
int ADAPTER_OFF = 1;
int ADAPTER_OFF_HELP = 2;
int REQUEST_PERMISSIONS = 3;
int REQUEST_LOCATION_SERVICES = 4;
int NEED_LOCATION_PERMISSION_HELP = 5;
int RESTART_SEARCH = 6;
}
/**
* Creates the BluetoothChooserDialog.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public BluetoothChooserDialog(WindowAndroid windowAndroid, String origin, int securityLevel,
BluetoothChooserAndroidDelegate delegate, long nativeBluetoothChooserDialogPtr) {
mWindowAndroid = windowAndroid;
mActivity = windowAndroid.getActivity().get();
assert mActivity != null;
mContext = windowAndroid.getContext().get();
assert mContext != null;
mOrigin = origin;
mSecurityLevel = securityLevel;
mDelegate = delegate;
mNativeBluetoothChooserDialogPtr = nativeBluetoothChooserDialogPtr;
mAdapter = BluetoothAdapter.getDefaultAdapter();
// Initialize icons.
mConnectedIcon = getIconWithRowIconColorStateList(R.drawable.ic_bluetooth_connected);
mConnectedIconDescription = mContext.getString(R.string.bluetooth_device_connected);
mSignalStrengthLevelIcon = new Drawable[] {
getIconWithRowIconColorStateList(R.drawable.ic_signal_cellular_0_bar),
getIconWithRowIconColorStateList(R.drawable.ic_signal_cellular_1_bar),
getIconWithRowIconColorStateList(R.drawable.ic_signal_cellular_2_bar),
getIconWithRowIconColorStateList(R.drawable.ic_signal_cellular_3_bar),
getIconWithRowIconColorStateList(R.drawable.ic_signal_cellular_4_bar)};
if (mAdapter == null) {
Log.i(TAG, "BluetoothChooserDialog: Default Bluetooth adapter not found.");
}
mAdapterOffStatus = SpanApplier.applySpans(
mContext.getString(R.string.bluetooth_adapter_off_help),
new SpanInfo("<link>", "</link>", createLinkSpan(LinkType.ADAPTER_OFF_HELP)));
}
private Drawable getIconWithRowIconColorStateList(int icon) {
Resources res = mContext.getResources();
Drawable drawable = VectorDrawableCompat.create(res, icon, mContext.getTheme());
DrawableCompat.setTintList(drawable,
AppCompatResources.getColorStateList(
mContext, R.color.item_chooser_row_icon_color));
return drawable;
}
/**
* Show the BluetoothChooserDialog.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
public void show() {
SpannableString origin = new SpannableString(mOrigin);
final boolean useDarkColors = !ColorUtils.inNightMode(mContext);
AutocompleteSchemeClassifier autocompleteSchemeClassifier =
mDelegate.createAutocompleteSchemeClassifier();
OmniboxUrlEmphasizer.emphasizeUrl(origin, mContext.getResources(),
autocompleteSchemeClassifier, mSecurityLevel, false, useDarkColors, true);
autocompleteSchemeClassifier.destroy();
// Construct a full string and replace the origin text with emphasized version.
SpannableString title =
new SpannableString(mContext.getString(R.string.bluetooth_dialog_title, mOrigin));
int start = title.toString().indexOf(mOrigin);
TextUtils.copySpansFrom(origin, 0, origin.length(), Object.class, title, start);
String noneFound = mContext.getString(R.string.bluetooth_not_found);
SpannableString searching = SpanApplier.applySpans(
mContext.getString(R.string.bluetooth_searching),
new SpanInfo("<link>", "</link>", createLinkSpan(LinkType.EXPLAIN_BLUETOOTH)));
String positiveButton = mContext.getString(R.string.bluetooth_confirm_button);
SpannableString statusIdleNoneFound = SpanApplier.applySpans(
mContext.getString(R.string.bluetooth_not_seeing_it_idle),
new SpanInfo("<link1>", "</link1>", createLinkSpan(LinkType.EXPLAIN_BLUETOOTH)),
new SpanInfo("<link2>", "</link2>", createLinkSpan(LinkType.RESTART_SEARCH)));
SpannableString statusActive = searching;
SpannableString statusIdleSomeFound = statusIdleNoneFound;
ItemChooserDialog.ItemChooserLabels labels =
new ItemChooserDialog.ItemChooserLabels(title, searching, noneFound, statusActive,
statusIdleNoneFound, statusIdleSomeFound, positiveButton);
mItemChooserDialog = new ItemChooserDialog(mContext, mActivity.getWindow(), this, labels);
mActivity.registerReceiver(mLocationModeBroadcastReceiver,
new IntentFilter(LocationManager.MODE_CHANGED_ACTION));
mIsLocationModeChangedReceiverRegistered = true;
}
// Called to report the dialog's results back to native code.
private void finishDialog(int resultCode, String id) {
if (mIsLocationModeChangedReceiverRegistered) {
mActivity.unregisterReceiver(mLocationModeBroadcastReceiver);
mIsLocationModeChangedReceiverRegistered = false;
}
if (mNativeBluetoothChooserDialogPtr != 0) {
Natives jni = BluetoothChooserDialogJni.get();
jni.onDialogFinished(mNativeBluetoothChooserDialogPtr, resultCode, id);
}
}
@Override
public void onItemSelected(String id) {
if (id.isEmpty()) {
finishDialog(BluetoothChooserEvent.CANCELLED, "");
} else {
finishDialog(BluetoothChooserEvent.SELECTED, id);
}
}
@Override
public void onRequestPermissionsResult(String[] permissions, int[] grantResults) {
// The chooser might have been closed during the request.
if (mNativeBluetoothChooserDialogPtr == 0) return;
if (checkLocationServicesAndPermission()) {
mItemChooserDialog.clear();
Natives jni = BluetoothChooserDialogJni.get();
jni.restartSearch(mNativeBluetoothChooserDialogPtr);
}
}
// Returns true if Location Services is on and Chrome has permission to see the user's location.
private boolean checkLocationServicesAndPermission() {
final boolean havePermission = hasSystemPermissions(mWindowAndroid);
// Location services are not required on Android S+ to use Bluetooth if the application has
// Nearby Devices permission and has set the neverForLocation flag on the BLUETOOTH_SCAN
// permission in its manifest.
boolean needsLocationServices = false;
// TODO(b/183501112): Remove the targetsAtLeastS() check once Chrome starts compiling
// against the S SDK.
if (!BuildInfo.targetsAtLeastS() || !BuildInfo.isAtLeastS()) {
needsLocationServices = !LocationUtils.getInstance().isSystemLocationSettingEnabled();
}
if (!havePermission && !canRequestSystemPermissions(mWindowAndroid)) {
// Immediately close the dialog because the user has asked Chrome not to request the
// necessary permissions.
finishDialog(BluetoothChooserEvent.DENIED_PERMISSION, "");
return false;
}
// Compute the message to show the user.
final SpanInfo permissionSpan = new SpanInfo("<permission_link>", "</permission_link>",
createLinkSpan(LinkType.REQUEST_PERMISSIONS));
final SpanInfo servicesSpan = new SpanInfo("<services_link>", "</services_link>",
createLinkSpan(LinkType.REQUEST_LOCATION_SERVICES));
final SpannableString needPermissionMessage;
if (havePermission) {
if (needsLocationServices) {
needPermissionMessage = SpanApplier.applySpans(
mContext.getString(R.string.bluetooth_need_location_services_on),
servicesSpan);
} else {
// We don't need to request anything.
return true;
}
} else {
if (needsLocationServices) {
needPermissionMessage = SpanApplier.applySpans(
mContext.getString(
R.string.bluetooth_need_location_permission_and_services_on),
permissionSpan, servicesSpan);
} else {
// TODO(b/183501112): Remove the targetsAtLeastS() check once Chrome starts
// compiling against the S SDK.
if (BuildInfo.targetsAtLeastS() && BuildInfo.isAtLeastS()) {
needPermissionMessage = SpanApplier.applySpans(
mContext.getString(R.string.bluetooth_need_nearby_devices_permission),
permissionSpan);
} else {
needPermissionMessage = SpanApplier.applySpans(
mContext.getString(R.string.bluetooth_need_location_permission),
permissionSpan);
}
}
}
SpannableString needPermissionStatus = SpanApplier.applySpans(
mContext.getString(R.string.bluetooth_need_location_permission_help),
new SpanInfo("<link>", "</link>",
createLinkSpan(LinkType.NEED_LOCATION_PERMISSION_HELP)));
mItemChooserDialog.setErrorState(needPermissionMessage, needPermissionStatus);
return false;
}
private NoUnderlineClickableSpan createLinkSpan(@LinkType int linkType) {
return new NoUnderlineClickableSpan(
mContext.getResources(), (view) -> onBluetoothLinkClick(view, linkType));
}
private void onBluetoothLinkClick(View view, @LinkType int linkType) {
if (mNativeBluetoothChooserDialogPtr == 0) return;
Natives jni = BluetoothChooserDialogJni.get();
switch (linkType) {
case LinkType.EXPLAIN_BLUETOOTH:
// No need to close the dialog here because
// ShowBluetoothOverviewLink will close it.
jni.showBluetoothOverviewLink(mNativeBluetoothChooserDialogPtr);
break;
case LinkType.ADAPTER_OFF:
if (mAdapter != null && mAdapter.enable()) {
mItemChooserDialog.signalInitializingAdapter();
} else {
String unableToTurnOnAdapter =
mContext.getString(R.string.bluetooth_unable_to_turn_on_adapter);
mItemChooserDialog.setErrorState(unableToTurnOnAdapter, mAdapterOffStatus);
}
break;
case LinkType.ADAPTER_OFF_HELP:
jni.showBluetoothAdapterOffLink(mNativeBluetoothChooserDialogPtr);
break;
case LinkType.REQUEST_PERMISSIONS:
mItemChooserDialog.setIgnorePendingWindowFocusChangeForClose(true);
// TODO(b/183501112): Remove the targetsAtLeastS() check once Chrome starts
// compiling against the S SDK.
if (BuildInfo.targetsAtLeastS() && BuildInfo.isAtLeastS()) {
// TODO(b/183501112): Replace these permission strings with the actual Manifest
// constants once Chrome starts compiling against the S SDK.
mWindowAndroid.requestPermissions(
new String[] {"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH_CONNECT"},
BluetoothChooserDialog.this);
} else {
mWindowAndroid.requestPermissions(
new String[] {Manifest.permission.ACCESS_FINE_LOCATION},
BluetoothChooserDialog.this);
}
break;
case LinkType.REQUEST_LOCATION_SERVICES:
mItemChooserDialog.setIgnorePendingWindowFocusChangeForClose(true);
mActivity.startActivity(
LocationUtils.getInstance().getSystemLocationSettingsIntent());
break;
case LinkType.NEED_LOCATION_PERMISSION_HELP:
jni.showNeedLocationPermissionLink(mNativeBluetoothChooserDialogPtr);
break;
case LinkType.RESTART_SEARCH:
mItemChooserDialog.clear();
jni.restartSearch(mNativeBluetoothChooserDialogPtr);
break;
default:
assert false;
}
// Get rid of the highlight background on selection.
view.invalidate();
}
private static boolean hasSystemPermissions(WindowAndroid windowAndroid) {
// TODO(b/183501112): Remove the targetsAtLeastS() check once Chrome starts compiling
// against the S SDK.
if (BuildInfo.targetsAtLeastS() && BuildInfo.isAtLeastS()) {
// TODO(b/183501112): Replace these permission strings with the actual Manifest
// constants once Chrome starts compiling against the S SDK.
return windowAndroid.hasPermission("android.permission.BLUETOOTH_SCAN")
&& windowAndroid.hasPermission("android.permission.BLUETOOTH_CONNECT");
}
return windowAndroid.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION);
}
private static boolean canRequestSystemPermissions(WindowAndroid windowAndroid) {
// TODO(b/183501112): Remove the targetsAtLeastS() check once Chrome starts compiling
// against the S SDK.
if (BuildInfo.targetsAtLeastS() && BuildInfo.isAtLeastS()) {
// TODO(b/183501112): Replace these permission strings with the actual Manifest
// constants once Chrome starts compiling against the S SDK.
return windowAndroid.canRequestPermission("android.permission.BLUETOOTH_SCAN")
&& windowAndroid.canRequestPermission("android.permission.BLUETOOTH_CONNECT");
}
return windowAndroid.canRequestPermission(Manifest.permission.ACCESS_FINE_LOCATION);
}
@CalledByNative
private static BluetoothChooserDialog create(WindowAndroid windowAndroid, String origin,
int securityLevel, BluetoothChooserAndroidDelegate delegate,
long nativeBluetoothChooserDialogPtr) {
if (!hasSystemPermissions(windowAndroid) && !canRequestSystemPermissions(windowAndroid)) {
// If we can't even ask for enough permission to scan for Bluetooth devices, don't open
// the dialog.
return null;
}
BluetoothChooserDialog dialog = new BluetoothChooserDialog(
windowAndroid, origin, securityLevel, delegate, nativeBluetoothChooserDialogPtr);
dialog.show();
return dialog;
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@CalledByNative
public void addOrUpdateDevice(
String deviceId, String deviceName, boolean isGATTConnected, int signalStrengthLevel) {
Drawable icon = null;
String iconDescription = null;
if (isGATTConnected) {
icon = mConnectedIcon.getConstantState().newDrawable();
iconDescription = mConnectedIconDescription;
} else if (signalStrengthLevel != -1) {
icon = mSignalStrengthLevelIcon[signalStrengthLevel].getConstantState().newDrawable();
iconDescription = mContext.getResources().getQuantityString(
R.plurals.signal_strength_level_n_bars, signalStrengthLevel,
signalStrengthLevel);
}
mItemChooserDialog.addOrUpdateItem(deviceId, deviceName, icon, iconDescription);
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@CalledByNative
public void closeDialog() {
mNativeBluetoothChooserDialogPtr = 0;
mItemChooserDialog.dismiss();
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@CalledByNative
public void notifyAdapterTurnedOff() {
SpannableString adapterOffMessage =
SpanApplier.applySpans(mContext.getString(R.string.bluetooth_adapter_off),
new SpanInfo("<link>", "</link>", createLinkSpan(LinkType.ADAPTER_OFF)));
mItemChooserDialog.setErrorState(adapterOffMessage, mAdapterOffStatus);
}
@CalledByNative
private void notifyAdapterTurnedOn() {
mItemChooserDialog.clear();
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@CalledByNative
public void notifyDiscoveryState(@DiscoveryMode int discoveryState) {
switch (discoveryState) {
case DiscoveryMode.DISCOVERY_FAILED_TO_START: {
// FAILED_TO_START might be caused by a missing Location
// permission or by the Location service being turned off.
// Check, and show a request if so.
checkLocationServicesAndPermission();
break;
}
case DiscoveryMode.DISCOVERY_IDLE: {
mItemChooserDialog.setIdleState();
break;
}
default: {
// TODO(jyasskin): Report the new state to the user.
break;
}
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@NativeMethods
public interface Natives {
void onDialogFinished(long nativeBluetoothChooserAndroid, int eventType, String deviceId);
void restartSearch(long nativeBluetoothChooserAndroid);
// Help links.
void showBluetoothOverviewLink(long nativeBluetoothChooserAndroid);
void showBluetoothAdapterOffLink(long nativeBluetoothChooserAndroid);
void showNeedLocationPermissionLink(long nativeBluetoothChooserAndroid);
}
}