| // 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); |
| } |
| } |