chromium/components/permissions/android/java/src/org/chromium/components/permissions/BluetoothChooserDialog.java

// Copyright 2015 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.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 org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.components.browser_ui.util.TraceEventVectorDrawableCompat;
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.WindowAndroid;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.permissions.PermissionCallback;
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).
 *
 * <p>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;

    // Should the "adapter off" message be shown once Bluetooth permission is granted?
    private boolean mAdapterOff;

    @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()) return;

                    mItemChooserDialog.clear();

                    if (mAdapterOff) {
                        notifyAdapterTurnedOff();
                        return;
                    }

                    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 = TraceEventVectorDrawableCompat.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,
                autocompleteSchemeClassifier,
                mSecurityLevel,
                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);

        ContextUtils.registerProtectedBroadcastReceiver(
                mActivity,
                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()) return;

        mItemChooserDialog.clear();

        if (mAdapterOff) {
            notifyAdapterTurnedOff();
            return;
        }

        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 =
                PermissionUtil.hasSystemPermissionsForBluetooth(mWindowAndroid);
        boolean needsLocationServices = PermissionUtil.needsLocationServicesForBluetooth();

        if (!havePermission
                && !PermissionUtil.canRequestSystemPermissionsForBluetooth(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) {
                // If it needs locations services, it implicitly means it is a version
                // lower than Android S, so we can assume the system permission
                // needed is location permission.
                int resourceId = R.string.bluetooth_need_location_permission_and_services_on;
                needPermissionMessage =
                        SpanApplier.applySpans(
                                mContext.getString(resourceId), permissionSpan, servicesSpan);
            } else {
                if (PermissionUtil.needsNearbyDevicesPermissionForBluetooth(mWindowAndroid)) {
                    int resourceId = R.string.bluetooth_need_nearby_devices_permission;
                    needPermissionMessage =
                            SpanApplier.applySpans(mContext.getString(resourceId), permissionSpan);
                } else {
                    int resourceId = R.string.bluetooth_need_location_permission;
                    needPermissionMessage =
                            SpanApplier.applySpans(mContext.getString(resourceId), 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, (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);
                PermissionUtil.requestSystemPermissionsForBluetooth(
                        mWindowAndroid, BluetoothChooserDialog.this);
                break;
            case LinkType.REQUEST_LOCATION_SERVICES:
                mItemChooserDialog.setIgnorePendingWindowFocusChangeForClose(true);
                PermissionUtil.requestLocationServices(mWindowAndroid);
                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();
    }

    @CalledByNative
    @VisibleForTesting
    public static BluetoothChooserDialog create(
            WindowAndroid windowAndroid,
            String origin,
            int securityLevel,
            BluetoothChooserAndroidDelegate delegate,
            long nativeBluetoothChooserDialogPtr) {
        if (!PermissionUtil.hasSystemPermissionsForBluetooth(windowAndroid)
                && !PermissionUtil.canRequestSystemPermissionsForBluetooth(windowAndroid)) {
            // If we can't even ask for enough permission to scan for Bluetooth devices, don't open
            // the dialog.
            return null;
        }

        // Avoid showing the chooser when ModalDialogManager indicates that
        // tab-modal or app-modal dialogs are suspended.
        // TODO(crbug.com/41483591): Integrate BluetoothChooserDialog with
        // ModalDialogManager.
        ModalDialogManager modalDialogManager = windowAndroid.getModalDialogManager();
        if (modalDialogManager != null
                && (modalDialogManager.isSuspended(ModalDialogManager.ModalDialogType.TAB)
                        || modalDialogManager.isSuspended(
                                ModalDialogManager.ModalDialogType.APP))) {
            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() {
        mAdapterOff = true;

        // Permission is required to turn the adapter on so make sure to ask for that first.
        if (checkLocationServicesAndPermission()) {
            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() {
        mAdapterOff = false;
        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.PACKAGE_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);
    }
}