blob: e5a1cb9f42d74c1a49dca11d17adaadf26b3bc2a [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.settings.bluetooth;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.admin.DevicePolicyManager;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.os.Bundle;
import android.os.Process;
import android.os.UserHandle;
import android.os.UserManager;
import android.text.TextUtils;
import androidx.activity.ComponentActivity;
import androidx.annotation.VisibleForTesting;
import com.android.car.settings.R;
import com.android.car.settings.common.Logger;
import com.android.car.ui.AlertDialogBuilder;
import com.android.settingslib.bluetooth.BluetoothDiscoverableTimeoutReceiver;
import com.android.settingslib.bluetooth.LocalBluetoothAdapter;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.core.lifecycle.HideNonSystemOverlayMixin;
import java.util.List;
/**
* This {@link Activity} handles requests to toggle Bluetooth by collecting user
* consent and waiting until the state change is completed. It can also be used to make the device
* explicitly discoverable for a given amount of time.
*/
public class BluetoothRequestPermissionActivity extends ComponentActivity {
private static final Logger LOG = new Logger(BluetoothRequestPermissionActivity.class);
@VisibleForTesting
static final int REQUEST_UNKNOWN = 0;
@VisibleForTesting
static final int REQUEST_ENABLE = 1;
@VisibleForTesting
static final int REQUEST_DISABLE = 2;
@VisibleForTesting
static final int REQUEST_ENABLE_DISCOVERABLE = 3;
private static final int DISCOVERABLE_TIMEOUT_TWO_MINUTES = 120;
private static final int DISCOVERABLE_TIMEOUT_ONE_HOUR = 3600;
@VisibleForTesting
static final String EXTRA_BYPASS_CONFIRM_DIALOG = "bypassConfirmDialog";
@VisibleForTesting
static final int DEFAULT_DISCOVERABLE_TIMEOUT = DISCOVERABLE_TIMEOUT_TWO_MINUTES;
@VisibleForTesting
static final int MAX_DISCOVERABLE_TIMEOUT = DISCOVERABLE_TIMEOUT_ONE_HOUR;
private AlertDialog mDialog;
private boolean mBypassConfirmDialog = false;
private int mRequest;
private int mTimeout = DEFAULT_DISCOVERABLE_TIMEOUT;
@NonNull
private CharSequence mAppLabel;
private LocalBluetoothAdapter mLocalBluetoothAdapter;
private LocalBluetoothManager mLocalBluetoothManager;
private StateChangeReceiver mReceiver;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getLifecycle().addObserver(new HideNonSystemOverlayMixin(this));
mRequest = parseIntent();
if (mRequest == REQUEST_UNKNOWN) {
finishWithResult(RESULT_CANCELED);
return;
}
mLocalBluetoothManager = LocalBluetoothManager.getInstance(
getApplicationContext(), /* onInitCallback= */ null);
if (mLocalBluetoothManager == null) {
LOG.e("Bluetooth is not supported on this device");
finishWithResult(RESULT_CANCELED);
}
mLocalBluetoothAdapter = mLocalBluetoothManager.getBluetoothAdapter();
int btState = mLocalBluetoothAdapter.getState();
switch (mRequest) {
case REQUEST_DISABLE:
switch (btState) {
case BluetoothAdapter.STATE_OFF:
case BluetoothAdapter.STATE_TURNING_OFF:
proceedAndFinish();
break;
case BluetoothAdapter.STATE_ON:
case BluetoothAdapter.STATE_TURNING_ON:
mDialog = createRequestDisableBluetoothDialog();
mDialog.show();
break;
default:
LOG.e("Unknown adapter state: " + btState);
finishWithResult(RESULT_CANCELED);
break;
}
break;
case REQUEST_ENABLE:
switch (btState) {
case BluetoothAdapter.STATE_OFF:
case BluetoothAdapter.STATE_TURNING_OFF:
mDialog = createRequestEnableBluetoothDialog();
mDialog.show();
break;
case BluetoothAdapter.STATE_ON:
case BluetoothAdapter.STATE_TURNING_ON:
proceedAndFinish();
break;
default:
LOG.e("Unknown adapter state: " + btState);
finishWithResult(RESULT_CANCELED);
break;
}
break;
case REQUEST_ENABLE_DISCOVERABLE:
switch (btState) {
case BluetoothAdapter.STATE_OFF:
case BluetoothAdapter.STATE_TURNING_OFF:
case BluetoothAdapter.STATE_TURNING_ON:
/*
* Strictly speaking STATE_TURNING_ON belong with STATE_ON; however, BT
* may not be ready when the user clicks yes and we would fail to turn on
* discovery mode. We still show the dialog and handle this case via the
* broadcast receiver.
*/
if (isSetupWizardDialogBypass()) {
/*
* In some cases, users may get to the setup wizard's bluetooth fragment
* while in this state. We still need to wait until we reach STATE_ON
* before enabling discovery mode but without showing a dialog.
*/
enableBluetoothWithWaitingDialog(/* dialogToShowOnWait= */ null);
} else {
mDialog = createRequestEnableBluetoothDialogWithTimeout(mTimeout);
mDialog.show();
}
break;
case BluetoothAdapter.STATE_ON:
// Allow SetupWizard specifically to skip the discoverability dialog.
if (isSetupWizardDialogBypass()) {
proceedAndFinish();
} else {
mDialog = createDiscoverableConfirmDialog(mTimeout);
mDialog.show();
}
break;
default:
LOG.e("Unknown adapter state: " + btState);
finishWithResult(RESULT_CANCELED);
break;
}
break;
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mReceiver != null) {
unregisterReceiver(mReceiver);
}
}
private boolean isSetupWizardDialogBypass() {
String callerName = getCallingPackage();
return mBypassConfirmDialog && callerName != null
&& callerName.equals(getSetupWizardPackageName());
}
@Nullable
private String getSetupWizardPackageName() {
Intent intent = new Intent(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_SETUP_WIZARD);
List<ResolveInfo> matches = getPackageManager().queryIntentActivities(intent,
PackageManager.MATCH_SYSTEM_ONLY | PackageManager.MATCH_DIRECT_BOOT_AWARE
| PackageManager.MATCH_DIRECT_BOOT_UNAWARE
| PackageManager.MATCH_DISABLED_COMPONENTS);
if (matches.size() == 1) {
return matches.get(0).activityInfo.packageName;
} else {
LOG.e("There should probably be exactly one setup wizard; found " + matches.size()
+ ": matches=" + matches);
return null;
}
}
private void proceedAndFinish() {
if (mRequest == REQUEST_ENABLE_DISCOVERABLE) {
finishWithResult(setDiscoverable(mTimeout));
} else {
finishWithResult(RESULT_OK);
}
}
// Returns the code that should be used to finish the activity.
private int setDiscoverable(int timeoutSeconds) {
if (!mLocalBluetoothAdapter.setScanMode(BluetoothAdapter.SCAN_MODE_CONNECTABLE_DISCOVERABLE,
timeoutSeconds)) {
return RESULT_CANCELED;
}
// If already in discoverable mode, this will extend the timeout.
long endTime = System.currentTimeMillis() + (long) timeoutSeconds * 1000;
BluetoothUtils.persistDiscoverableEndTimestamp(/* context= */ this, endTime);
if (timeoutSeconds > 0) {
BluetoothDiscoverableTimeoutReceiver.setDiscoverableAlarm(/* context= */ this, endTime);
}
int returnCode = timeoutSeconds;
return returnCode < RESULT_FIRST_USER ? RESULT_FIRST_USER : returnCode;
}
private void finishWithResult(int result) {
if (mDialog != null) {
mDialog.dismiss();
}
setResult(result);
finish();
}
private int parseIntent() {
int request;
Intent intent = getIntent();
if (intent == null) {
return REQUEST_UNKNOWN;
}
switch (intent.getAction()) {
case BluetoothAdapter.ACTION_REQUEST_ENABLE:
request = REQUEST_ENABLE;
break;
case BluetoothAdapter.ACTION_REQUEST_DISABLE:
request = REQUEST_DISABLE;
break;
case BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE:
request = REQUEST_ENABLE_DISCOVERABLE;
mTimeout = intent.getIntExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION,
DEFAULT_DISCOVERABLE_TIMEOUT);
mBypassConfirmDialog = intent.getBooleanExtra(EXTRA_BYPASS_CONFIRM_DIALOG, false);
if (mTimeout < 1 || mTimeout > MAX_DISCOVERABLE_TIMEOUT) {
mTimeout = DEFAULT_DISCOVERABLE_TIMEOUT;
}
break;
default:
LOG.e("Error: this activity may be started only with intent "
+ BluetoothAdapter.ACTION_REQUEST_ENABLE);
return REQUEST_UNKNOWN;
}
String packageName = getLaunchedFromPackage();
int mCallingUid = getLaunchedFromUid();
if (UserHandle.isSameApp(mCallingUid, Process.SYSTEM_UID)
&& getIntent().getStringExtra(Intent.EXTRA_PACKAGE_NAME) != null) {
packageName = getIntent().getStringExtra(Intent.EXTRA_PACKAGE_NAME);
}
if (!UserHandle.isSameApp(mCallingUid, Process.SYSTEM_UID)
&& getIntent().getStringExtra(Intent.EXTRA_PACKAGE_NAME) != null) {
LOG.w("Non-system Uid: " + mCallingUid + " tried to override packageName");
}
if (!mBypassConfirmDialog && !TextUtils.isEmpty(packageName)) {
try {
ApplicationInfo applicationInfo = getPackageManager().getApplicationInfo(
packageName, 0);
mAppLabel = applicationInfo.loadLabel(getPackageManager());
} catch (PackageManager.NameNotFoundException e) {
LOG.e("Couldn't find app with package name " + packageName);
return REQUEST_UNKNOWN;
}
}
return request;
}
private AlertDialog createWaitingDialog() {
int message = mRequest == REQUEST_DISABLE ? R.string.bluetooth_turning_off
: R.string.bluetooth_turning_on;
return new AlertDialogBuilder(/* context= */ this)
.setMessage(message)
.setCancelable(false).setOnCancelListener(
dialog -> finishWithResult(RESULT_CANCELED))
.create();
}
// Assumes {@code timeoutSeconds} > 0.
private AlertDialog createDiscoverableConfirmDialog(int timeoutSeconds) {
String message = mAppLabel != null
? getString(R.string.bluetooth_ask_discovery, mAppLabel, timeoutSeconds)
: getString(R.string.bluetooth_ask_discovery_no_name, timeoutSeconds);
return new AlertDialogBuilder(/* context= */ this)
.setMessage(message)
.setPositiveButton(R.string.allow, (dialog, which) -> proceedAndFinish())
.setNegativeButton(R.string.deny,
(dialog, which) -> finishWithResult(RESULT_CANCELED))
.setOnCancelListener(dialog -> finishWithResult(RESULT_CANCELED))
.create();
}
private AlertDialog createRequestEnableBluetoothDialog() {
String message = mAppLabel != null
? getString(R.string.bluetooth_ask_enablement, mAppLabel)
: getString(R.string.bluetooth_ask_enablement_no_name);
return new AlertDialogBuilder(/* context= */ this)
.setMessage(message)
.setPositiveButton(R.string.allow, this::onConfirmEnableBluetooth)
.setNegativeButton(R.string.deny,
(dialog, which) -> finishWithResult(RESULT_CANCELED))
.setOnCancelListener(dialog -> finishWithResult(RESULT_CANCELED))
.create();
}
// Assumes {@code timeoutSeconds} > 0.
private AlertDialog createRequestEnableBluetoothDialogWithTimeout(int timeoutSeconds) {
String message = mAppLabel != null
? getString(R.string.bluetooth_ask_enablement_and_discovery, mAppLabel,
timeoutSeconds)
: getString(R.string.bluetooth_ask_enablement_and_discovery_no_name,
timeoutSeconds);
return new AlertDialogBuilder(/* context= */ this)
.setMessage(message)
.setPositiveButton(R.string.allow, this::onConfirmEnableBluetooth)
.setNegativeButton(R.string.deny,
(dialog, which) -> finishWithResult(RESULT_CANCELED))
.setOnCancelListener(dialog -> finishWithResult(RESULT_CANCELED))
.create();
}
private void onConfirmEnableBluetooth(DialogInterface dialog, int which) {
UserManager userManager = getSystemService(UserManager.class);
if (userManager.hasUserRestriction(UserManager.DISALLOW_BLUETOOTH)) {
// If Bluetooth is disallowed, don't try to enable it, show policy
// transparency message instead.
DevicePolicyManager dpm = getSystemService(DevicePolicyManager.class);
Intent intent = dpm.createAdminSupportIntent(
UserManager.DISALLOW_BLUETOOTH);
if (intent != null) {
startActivity(intent);
}
return;
}
if (mRequest == REQUEST_ENABLE) {
enableBluetoothWithWaitingDialog(createWaitingDialog());
} else {
enableBluetoothWithWaitingDialog(createDiscoverableConfirmDialog(mTimeout));
}
}
/*
* Ensure bluetooth is enabled and then check if it is in STATE_ON. If it isn't, register
* the broadcast receiver to wait for the state to change and show a waiting dialog if provided.
*/
private void enableBluetoothWithWaitingDialog(@Nullable AlertDialog dialogToShowOnWait) {
mLocalBluetoothAdapter.enable();
int desiredState = BluetoothAdapter.STATE_ON;
if (mLocalBluetoothAdapter.getState() == desiredState) {
proceedAndFinish();
} else {
// Register this receiver to listen for state change after the enabling has started.
mReceiver = new StateChangeReceiver(desiredState);
registerReceiver(mReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
if (dialogToShowOnWait != null) {
mDialog = dialogToShowOnWait;
mDialog.show();
}
}
}
private AlertDialog createRequestDisableBluetoothDialog() {
String message = mAppLabel != null
? getString(R.string.bluetooth_ask_disablement, mAppLabel)
: getString(R.string.bluetooth_ask_disablement_no_name);
return new AlertDialogBuilder(/* context= */ this)
.setMessage(message)
.setPositiveButton(R.string.allow, this::onConfirmDisableBluetooth)
.setNegativeButton(R.string.deny,
(dialog, which) -> finishWithResult(RESULT_CANCELED))
.setOnCancelListener(dialog -> finishWithResult(RESULT_CANCELED))
.create();
}
private void onConfirmDisableBluetooth(DialogInterface dialog, int which) {
mLocalBluetoothAdapter.disable();
int desiredState = BluetoothAdapter.STATE_OFF;
if (mLocalBluetoothAdapter.getState() == desiredState) {
proceedAndFinish();
} else {
// Register this receiver to listen for state change after the disabling has started.
mReceiver = new StateChangeReceiver(desiredState);
registerReceiver(mReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
// Show dialog while waiting for disabling to complete.
mDialog = createWaitingDialog();
mDialog.show();
}
}
@VisibleForTesting
int getRequestType() {
return mRequest;
}
@VisibleForTesting
int getTimeout() {
return mTimeout;
}
@VisibleForTesting
AlertDialog getCurrentDialog() {
return mDialog;
}
@VisibleForTesting
StateChangeReceiver getCurrentReceiver() {
return mReceiver;
}
/**
* Listens for bluetooth state changes and finishes the activity if changed to the desired
* state. If the desired bluetooth state is not received in time, the activity is finished with
* {@link Activity#RESULT_CANCELED}.
*/
@VisibleForTesting
final class StateChangeReceiver extends BroadcastReceiver {
private static final long TOGGLE_TIMEOUT_MILLIS = 10000; // 10 sec
private final int mDesiredState;
StateChangeReceiver(int desiredState) {
mDesiredState = desiredState;
getWindow().getDecorView().postDelayed(() -> {
if (!isFinishing() && !isDestroyed()) {
finishWithResult(RESULT_CANCELED);
}
}, TOGGLE_TIMEOUT_MILLIS);
}
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null) {
return;
}
int currentState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE,
BluetoothDevice.ERROR);
if (mDesiredState == currentState) {
proceedAndFinish();
}
}
}
}