// 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.chrome.browser;
import android.Manifest;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.location.LocationManager;
import android.text.SpannableString;
import android.text.TextUtils;
import android.view.View;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.omnibox.OmniboxUrlEmphasizer;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.components.location.LocationUtils;
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;
/**
* 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.
*/
public class BluetoothChooserDialog
implements ItemChooserDialog.ItemSelectedCallback, WindowAndroid.PermissionCallback {
// These constants match BluetoothChooserAndroid::ShowDiscoveryState, and are used in
// notifyDiscoveryState().
static final int DISCOVERY_FAILED_TO_START = 0;
static final int DISCOVERING = 1;
static final int DISCOVERY_IDLE = 2;
// Values passed to nativeOnDialogFinished:eventType, and only used in the native function.
static final int DIALOG_FINISHED_DENIED_PERMISSION = 0;
static final int DIALOG_FINISHED_CANCELLED = 1;
static final int DIALOG_FINISHED_SELECTED = 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;
// The dialog to show to let the user pick a device.
ItemChooserDialog mItemChooserDialog;
// The origin for the site wanting to pair with the bluetooth devices.
String mOrigin;
// The security level of the connection to the site wanting to pair with the
// bluetooth devices. For valid values see SecurityStateModel::SecurityLevel.
int mSecurityLevel;
// A pointer back to the native part of the implementation for this dialog.
long mNativeBluetoothChooserDialogPtr;
// Used to keep track of when the Mode Changed Receiver is registered.
boolean mIsLocationModeChangedReceiverRegistered = false;
@VisibleForTesting
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();
nativeRestartSearch(mNativeBluetoothChooserDialogPtr);
}
}
};
// The type of link that is shown within the dialog.
private enum LinkType {
EXPLAIN_BLUETOOTH,
ADAPTER_OFF,
ADAPTER_OFF_HELP,
REQUEST_LOCATION_PERMISSION,
REQUEST_LOCATION_SERVICES,
NEED_LOCATION_PERMISSION_HELP,
RESTART_SEARCH,
}
/**
* Creates the BluetoothChooserDialog.
*/
@VisibleForTesting
BluetoothChooserDialog(WindowAndroid windowAndroid, String origin, int securityLevel,
long nativeBluetoothChooserDialogPtr) {
mWindowAndroid = windowAndroid;
mActivity = windowAndroid.getActivity().get();
assert mActivity != null;
mOrigin = origin;
mSecurityLevel = securityLevel;
mNativeBluetoothChooserDialogPtr = nativeBluetoothChooserDialogPtr;
}
/**
* Show the BluetoothChooserDialog.
*/
@VisibleForTesting
void show() {
// Emphasize the origin.
Profile profile = Profile.getLastUsedProfile();
SpannableString origin = new SpannableString(mOrigin);
OmniboxUrlEmphasizer.emphasizeUrl(
origin, mActivity.getResources(), profile, mSecurityLevel, false, true, true);
// Construct a full string and replace the origin text with emphasized version.
SpannableString title =
new SpannableString(mActivity.getString(R.string.bluetooth_dialog_title, mOrigin));
int start = title.toString().indexOf(mOrigin);
TextUtils.copySpansFrom(origin, 0, origin.length(), Object.class, title, start);
String message = mActivity.getString(R.string.bluetooth_not_found);
SpannableString noneFound = SpanApplier.applySpans(
message, new SpanInfo("<link>", "</link>",
new BluetoothClickableSpan(LinkType.RESTART_SEARCH, mActivity)));
SpannableString searching = SpanApplier.applySpans(
mActivity.getString(R.string.bluetooth_searching),
new SpanInfo("<link>", "</link>",
new BluetoothClickableSpan(LinkType.EXPLAIN_BLUETOOTH, mActivity)));
String positiveButton = mActivity.getString(R.string.bluetooth_confirm_button);
SpannableString statusIdleNoneFound = SpanApplier.applySpans(
mActivity.getString(R.string.bluetooth_not_seeing_it_idle_none_found),
new SpanInfo("<link>", "</link>",
new BluetoothClickableSpan(LinkType.EXPLAIN_BLUETOOTH, mActivity)));
SpannableString statusActive = SpanApplier.applySpans(
mActivity.getString(R.string.bluetooth_not_seeing_it),
new SpanInfo("<link>", "</link>",
new BluetoothClickableSpan(LinkType.EXPLAIN_BLUETOOTH, mActivity)));
SpannableString statusIdleSomeFound = SpanApplier.applySpans(
mActivity.getString(R.string.bluetooth_not_seeing_it_idle_some_found),
new SpanInfo("<link1>", "</link1>",
new BluetoothClickableSpan(LinkType.EXPLAIN_BLUETOOTH, mActivity)),
new SpanInfo("<link2>", "</link2>",
new BluetoothClickableSpan(LinkType.RESTART_SEARCH, mActivity)));
ItemChooserDialog.ItemChooserLabels labels =
new ItemChooserDialog.ItemChooserLabels(title, searching, noneFound, statusActive,
statusIdleNoneFound, statusIdleSomeFound, positiveButton);
mItemChooserDialog = new ItemChooserDialog(mActivity, 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) {
nativeOnDialogFinished(mNativeBluetoothChooserDialogPtr, resultCode, id);
}
}
@Override
public void onItemSelected(String id) {
if (id.isEmpty()) {
finishDialog(DIALOG_FINISHED_CANCELLED, "");
} else {
finishDialog(DIALOG_FINISHED_SELECTED, id);
}
}
@Override
public void onRequestPermissionsResult(String[] permissions, int[] grantResults) {
for (int i = 0; i < permissions.length; i++) {
if (permissions[i].equals(Manifest.permission.ACCESS_COARSE_LOCATION)) {
if (checkLocationServicesAndPermission()) {
mItemChooserDialog.clear();
nativeRestartSearch(mNativeBluetoothChooserDialogPtr);
}
return;
}
}
// If the location permission is not present, leave the currently-shown message in place.
}
// Returns true if Location Services is on and Chrome has permission to see the user's location.
private boolean checkLocationServicesAndPermission() {
final boolean havePermission = LocationUtils.getInstance().hasAndroidLocationPermission();
final boolean locationServicesOn =
LocationUtils.getInstance().isSystemLocationSettingEnabled();
if (!havePermission
&& !mWindowAndroid.canRequestPermission(
Manifest.permission.ACCESS_COARSE_LOCATION)) {
// Immediately close the dialog because the user has asked Chrome not to request the
// location permission.
finishDialog(DIALOG_FINISHED_DENIED_PERMISSION, "");
return false;
}
// Compute the message to show the user.
final SpanInfo permissionSpan = new SpanInfo("<permission_link>", "</permission_link>",
new BluetoothClickableSpan(LinkType.REQUEST_LOCATION_PERMISSION, mActivity));
final SpanInfo servicesSpan = new SpanInfo("<services_link>", "</services_link>",
new BluetoothClickableSpan(LinkType.REQUEST_LOCATION_SERVICES, mActivity));
final SpannableString needLocationMessage;
if (havePermission) {
if (locationServicesOn) {
// We don't need to request anything.
return true;
} else {
needLocationMessage = SpanApplier.applySpans(
mActivity.getString(R.string.bluetooth_need_location_services_on),
servicesSpan);
}
} else {
if (locationServicesOn) {
needLocationMessage = SpanApplier.applySpans(
mActivity.getString(R.string.bluetooth_need_location_permission),
permissionSpan);
} else {
needLocationMessage = SpanApplier.applySpans(
mActivity.getString(
R.string.bluetooth_need_location_permission_and_services_on),
permissionSpan, servicesSpan);
}
}
SpannableString needLocationStatus = SpanApplier.applySpans(
mActivity.getString(R.string.bluetooth_need_location_permission_help),
new SpanInfo("<link>", "</link>",
new BluetoothClickableSpan(
LinkType.NEED_LOCATION_PERMISSION_HELP, mActivity)));
mItemChooserDialog.setErrorState(needLocationMessage, needLocationStatus);
return false;
}
private class BluetoothClickableSpan extends NoUnderlineClickableSpan {
// The type of link this span represents.
private LinkType mLinkType;
private Context mContext;
BluetoothClickableSpan(LinkType linkType, Context context) {
mLinkType = linkType;
mContext = context;
}
@Override
public void onClick(View view) {
if (mNativeBluetoothChooserDialogPtr == 0) {
return;
}
switch (mLinkType) {
case EXPLAIN_BLUETOOTH: {
// No need to close the dialog here because
// ShowBluetoothOverviewLink will close it.
// TODO(ortuno): The BluetoothChooserDialog should dismiss
// itself when a new tab is opened or the current tab navigates.
// https://crbug.com/588127
nativeShowBluetoothOverviewLink(mNativeBluetoothChooserDialogPtr);
break;
}
case ADAPTER_OFF: {
Intent intent = new Intent();
intent.setAction(android.provider.Settings.ACTION_BLUETOOTH_SETTINGS);
mContext.startActivity(intent);
break;
}
case ADAPTER_OFF_HELP: {
nativeShowBluetoothAdapterOffLink(mNativeBluetoothChooserDialogPtr);
closeDialog();
break;
}
case REQUEST_LOCATION_PERMISSION: {
mWindowAndroid.requestPermissions(
new String[] {Manifest.permission.ACCESS_COARSE_LOCATION},
BluetoothChooserDialog.this);
break;
}
case REQUEST_LOCATION_SERVICES: {
mContext.startActivity(
LocationUtils.getInstance().getSystemLocationSettingsIntent());
break;
}
case NEED_LOCATION_PERMISSION_HELP: {
nativeShowNeedLocationPermissionLink(mNativeBluetoothChooserDialogPtr);
closeDialog();
break;
}
case RESTART_SEARCH: {
mItemChooserDialog.clear();
nativeRestartSearch(mNativeBluetoothChooserDialogPtr);
break;
}
default:
assert false;
}
// Get rid of the highlight background on selection.
view.invalidate();
}
}
@CalledByNative
private static BluetoothChooserDialog create(WindowAndroid windowAndroid, String origin,
int securityLevel, long nativeBluetoothChooserDialogPtr) {
if (!LocationUtils.getInstance().hasAndroidLocationPermission()
&& !windowAndroid.canRequestPermission(
Manifest.permission.ACCESS_COARSE_LOCATION)) {
// 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, nativeBluetoothChooserDialogPtr);
dialog.show();
return dialog;
}
@VisibleForTesting
@CalledByNative
void addOrUpdateDevice(String deviceId, String deviceName) {
mItemChooserDialog.addOrUpdateItem(
new ItemChooserDialog.ItemChooserRow(deviceId, deviceName));
}
@VisibleForTesting
@CalledByNative
void closeDialog() {
mNativeBluetoothChooserDialogPtr = 0;
mItemChooserDialog.dismiss();
}
@VisibleForTesting
@CalledByNative
void removeDevice(String deviceId) {
mItemChooserDialog.setEnabled(deviceId, false);
}
@VisibleForTesting
@CalledByNative
void notifyAdapterTurnedOff() {
SpannableString adapterOffMessage = SpanApplier.applySpans(
mActivity.getString(R.string.bluetooth_adapter_off),
new SpanInfo("<link>", "</link>",
new BluetoothClickableSpan(LinkType.ADAPTER_OFF, mActivity)));
SpannableString adapterOffStatus = SpanApplier.applySpans(
mActivity.getString(R.string.bluetooth_adapter_off_help),
new SpanInfo("<link>", "</link>",
new BluetoothClickableSpan(LinkType.ADAPTER_OFF_HELP, mActivity)));
mItemChooserDialog.setErrorState(adapterOffMessage, adapterOffStatus);
}
@CalledByNative
private void notifyAdapterTurnedOn() {
mItemChooserDialog.clear();
}
@VisibleForTesting
@CalledByNative
void notifyDiscoveryState(int discoveryState) {
switch (discoveryState) {
case 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 DISCOVERY_IDLE: {
mItemChooserDialog.setIdleState();
break;
}
default: {
// TODO(jyasskin): Report the new state to the user.
break;
}
}
}
@VisibleForTesting
native void nativeOnDialogFinished(
long nativeBluetoothChooserAndroid, int eventType, String deviceId);
@VisibleForTesting
native void nativeRestartSearch(long nativeBluetoothChooserAndroid);
// Help links.
@VisibleForTesting
native void nativeShowBluetoothOverviewLink(long nativeBluetoothChooserAndroid);
@VisibleForTesting
native void nativeShowBluetoothAdapterOffLink(long nativeBluetoothChooserAndroid);
@VisibleForTesting
native void nativeShowNeedLocationPermissionLink(long nativeBluetoothChooserAndroid);
}