blob: 0b2cce0666c500a86665b32206ec17f63ae0acd0 [file] [log] [blame]
/*
* Copyright (C) 2022 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.server.companion.presence;
import static android.bluetooth.BluetoothAdapter.ACTION_BLE_STATE_CHANGED;
import static android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED;
import static android.bluetooth.BluetoothAdapter.EXTRA_PREVIOUS_STATE;
import static android.bluetooth.BluetoothAdapter.EXTRA_STATE;
import static android.bluetooth.BluetoothAdapter.nameForState;
import static android.bluetooth.le.ScanCallback.SCAN_FAILED_ALREADY_STARTED;
import static android.bluetooth.le.ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED;
import static android.bluetooth.le.ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED;
import static android.bluetooth.le.ScanCallback.SCAN_FAILED_INTERNAL_ERROR;
import static android.bluetooth.le.ScanCallback.SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES;
import static android.bluetooth.le.ScanCallback.SCAN_FAILED_SCANNING_TOO_FREQUENTLY;
import static android.bluetooth.le.ScanSettings.CALLBACK_TYPE_ALL_MATCHES;
import static android.bluetooth.le.ScanSettings.CALLBACK_TYPE_FIRST_MATCH;
import static android.bluetooth.le.ScanSettings.CALLBACK_TYPE_MATCH_LOST;
import static android.bluetooth.le.ScanSettings.SCAN_MODE_LOW_POWER;
import static com.android.server.companion.presence.CompanionDevicePresenceMonitor.DEBUG;
import static com.android.server.companion.presence.Utils.btDeviceToString;
import static java.util.Objects.requireNonNull;
import android.annotation.MainThread;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
import android.companion.AssociationInfo;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
import android.util.Slog;
import com.android.server.companion.AssociationStore;
import com.android.server.companion.AssociationStore.ChangeType;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@SuppressLint("LongLogTag")
class BleCompanionDeviceScanner implements AssociationStore.OnChangeListener {
private static final String TAG = "CDM_BleCompanionDeviceScanner";
/**
* When using {@link ScanSettings#SCAN_MODE_LOW_POWER}, it usually takes from 20 seconds to
* 2 minutes for the BLE scanner to find advertisements sent from the same device.
* On the other hand, {@link android.bluetooth.BluetoothAdapter.LeScanCallback} will report
* {@link ScanSettings#CALLBACK_TYPE_MATCH_LOST MATCH_LOST} 10 sec after it finds the
* advertisement for the first time (add reports
* {@link ScanSettings#CALLBACK_TYPE_FIRST_MATCH FIRST_MATCH}).
* To avoid constantly reporting {@link Callback#onBleCompanionDeviceFound(int) onDeviceFound()}
* and {@link Callback#onBleCompanionDeviceLost(int) onDeviceLost()} (while the device is
* actually present) to its clients, {@link BleCompanionDeviceScanner}, will add 1-minute delay
* after it receives {@link ScanSettings#CALLBACK_TYPE_MATCH_LOST MATCH_LOST}.
*/
private static final int NOTIFY_DEVICE_LOST_DELAY = 2 * 60 * 1000; // 2 Min.
interface Callback {
void onBleCompanionDeviceFound(int associationId);
void onBleCompanionDeviceLost(int associationId);
}
private final @NonNull AssociationStore mAssociationStore;
private final @NonNull Callback mCallback;
private final @NonNull MainThreadHandler mMainThreadHandler;
// Non-null after init().
private @Nullable BluetoothAdapter mBtAdapter;
// Non-null after init() and when BLE is available. Otherwise - null.
private @Nullable BluetoothLeScanner mBleScanner;
// Only accessed from the Main thread.
private boolean mScanning = false;
BleCompanionDeviceScanner(
@NonNull AssociationStore associationStore, @NonNull Callback callback) {
mAssociationStore = associationStore;
mCallback = callback;
mMainThreadHandler = new MainThreadHandler();
}
@MainThread
void init(@NonNull Context context, @NonNull BluetoothAdapter btAdapter) {
if (DEBUG) Log.i(TAG, "init()");
if (mBtAdapter != null) {
throw new IllegalStateException(getClass().getSimpleName() + " is already initialized");
}
mBtAdapter = requireNonNull(btAdapter);
checkBleState();
registerBluetoothStateBroadcastReceiver(context);
mAssociationStore.registerListener(this);
}
@MainThread
final void restartScan() {
enforceInitialized();
if (DEBUG) Log.i(TAG , "restartScan()");
if (mBleScanner == null) {
if (DEBUG) Log.d(TAG, " > BLE is not available");
return;
}
stopScanIfNeeded();
startScan();
}
@Override
public void onAssociationChanged(@ChangeType int changeType, AssociationInfo association) {
// Simply restart scanning.
if (Looper.getMainLooper().isCurrentThread()) {
restartScan();
} else {
mMainThreadHandler.post(this::restartScan);
}
}
@MainThread
private void checkBleState() {
enforceInitialized();
final boolean bleAvailable = mBtAdapter.isLeEnabled();
if (DEBUG) {
Log.i(TAG, "checkBleState() bleAvailable=" + bleAvailable);
}
if ((bleAvailable && mBleScanner != null) || (!bleAvailable && mBleScanner == null)) {
// Nothing changed.
if (DEBUG) Log.i(TAG, " > BLE status did not change");
return;
}
if (bleAvailable) {
mBleScanner = mBtAdapter.getBluetoothLeScanner();
if (mBleScanner == null) {
// Oops, that's a race condition. Can return.
return;
}
if (DEBUG) Log.i(TAG, " > BLE is now available");
startScan();
} else {
if (DEBUG) Log.i(TAG, " > BLE is now unavailable");
stopScanIfNeeded();
mBleScanner = null;
}
}
@MainThread
private void startScan() {
enforceInitialized();
if (DEBUG) Log.i(TAG, "startScan()");
// This method should not be called if scan is already in progress.
if (mScanning) {
Slog.w(TAG, "Scan is already in progress.");
return;
}
// Neither should this method be called if the adapter is not available.
if (mBleScanner == null) {
Slog.w(TAG, "BLE is not available.");
return;
}
// Collect MAC addresses from all associations.
final Set<String> macAddresses = new HashSet<>();
for (AssociationInfo association : mAssociationStore.getAssociations()) {
if (!association.isNotifyOnDeviceNearby()) continue;
// Beware that BT stack does not consider low-case MAC addresses valid, while
// MacAddress.toString() return a low-case String.
final String macAddress = association.getDeviceMacAddressAsString();
if (macAddress != null) {
macAddresses.add(macAddress);
}
}
if (macAddresses.isEmpty()) {
if (DEBUG) Log.i(TAG, " > there are no (associated) devices to Scan for.");
return;
} else {
if (DEBUG) {
Log.d(TAG, " > addresses=(n=" + macAddresses.size() + ")"
+ "[" + String.join(", ", macAddresses) + "]");
}
}
final List<ScanFilter> filters = new ArrayList<>(macAddresses.size());
for (String macAddress : macAddresses) {
final ScanFilter filter = new ScanFilter.Builder()
.setDeviceAddress(macAddress)
.build();
filters.add(filter);
}
// BluetoothLeScanner will throw an IllegalStateException if startScan() is called while LE
// is not enabled.
if (mBtAdapter.isLeEnabled()) {
try {
mBleScanner.startScan(filters, SCAN_SETTINGS, mScanCallback);
mScanning = true;
} catch (IllegalStateException e) {
Slog.w(TAG, "Exception while starting BLE scanning", e);
}
} else {
Slog.w(TAG, "BLE scanning is not turned on");
}
}
private void stopScanIfNeeded() {
enforceInitialized();
if (DEBUG) Log.i(TAG, "stopScan()");
if (!mScanning) {
if (DEBUG) Log.d(TAG, " > not scanning.");
return;
}
// mScanCallback is non-null here - it cannot be null when mScanning is true.
// BluetoothLeScanner will throw an IllegalStateException if stopScan() is called while LE
// is not enabled.
if (mBtAdapter.isLeEnabled()) {
try {
mBleScanner.stopScan(mScanCallback);
} catch (IllegalStateException e) {
Slog.w(TAG, "Exception while stopping BLE scanning", e);
}
} else {
Slog.w(TAG, "BLE scanning is not turned on");
}
mScanning = false;
}
@MainThread
private void notifyDeviceFound(@NonNull BluetoothDevice device) {
if (DEBUG) Log.i(TAG, "notifyDevice_Found()" + btDeviceToString(device));
final List<AssociationInfo> associations =
mAssociationStore.getAssociationsByAddress(device.getAddress());
if (DEBUG) Log.d(TAG, " > associations=" + Arrays.toString(associations.toArray()));
for (AssociationInfo association : associations) {
mCallback.onBleCompanionDeviceFound(association.getId());
}
}
@MainThread
private void notifyDeviceLost(@NonNull BluetoothDevice device) {
if (DEBUG) Log.i(TAG, "notifyDevice_Lost()" + btDeviceToString(device));
final List<AssociationInfo> associations =
mAssociationStore.getAssociationsByAddress(device.getAddress());
if (DEBUG) Log.d(TAG, " > associations=" + Arrays.toString(associations.toArray()));
for (AssociationInfo association : associations) {
mCallback.onBleCompanionDeviceLost(association.getId());
}
}
private void registerBluetoothStateBroadcastReceiver(Context context) {
final BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
final int prevState = intent.getIntExtra(EXTRA_PREVIOUS_STATE, -1);
final int state = intent.getIntExtra(EXTRA_STATE, -1);
if (DEBUG) {
// The action is either STATE_CHANGED or BLE_STATE_CHANGED.
final String action =
intent.getAction().replace("android.bluetooth.adapter.", "bt.");
Log.d(TAG, "on(Broadcast)Receive() " + action + ": "
+ nameForBtState(prevState) + "->" + nameForBtState(state));
}
checkBleState();
}
};
final IntentFilter filter = new IntentFilter();
filter.addAction(ACTION_STATE_CHANGED);
filter.addAction(ACTION_BLE_STATE_CHANGED);
context.registerReceiver(receiver, filter);
}
private void enforceInitialized() {
if (mBtAdapter != null) return;
throw new IllegalStateException(getClass().getSimpleName() + " is not initialized");
}
private final ScanCallback mScanCallback = new ScanCallback() {
@MainThread
@Override
public void onScanResult(int callbackType, ScanResult result) {
final BluetoothDevice device = result.getDevice();
if (DEBUG) {
Log.d(TAG, "onScanResult() " + nameForBleScanCallbackType(callbackType)
+ " device=" + btDeviceToString(device));
Log.v(TAG, " > scanResult=" + result);
final List<AssociationInfo> associations =
mAssociationStore.getAssociationsByAddress(device.getAddress());
Log.v(TAG, " > associations=" + Arrays.toString(associations.toArray()));
}
switch (callbackType) {
case CALLBACK_TYPE_FIRST_MATCH:
if (mMainThreadHandler.hasNotifyDeviceLostMessages(device)) {
mMainThreadHandler.removeNotifyDeviceLostMessages(device);
return;
}
notifyDeviceFound(device);
break;
case CALLBACK_TYPE_MATCH_LOST:
mMainThreadHandler.sendNotifyDeviceLostDelayedMessage(device);
break;
default:
Slog.wtf(TAG, "Unexpected callback "
+ nameForBleScanCallbackType(callbackType));
break;
}
}
@MainThread
@Override
public void onScanFailed(int errorCode) {
if (DEBUG) Log.w(TAG, "onScanFailed() " + nameForBleScanErrorCode(errorCode));
mScanning = false;
}
};
@SuppressLint("HandlerLeak")
private class MainThreadHandler extends Handler {
private static final int NOTIFY_DEVICE_LOST = 1;
MainThreadHandler() {
super(Looper.getMainLooper());
}
@Override
public void handleMessage(@NonNull Message message) {
if (message.what != NOTIFY_DEVICE_LOST) return;
final BluetoothDevice device = (BluetoothDevice) message.obj;
notifyDeviceLost(device);
}
void sendNotifyDeviceLostDelayedMessage(BluetoothDevice device) {
final Message message = obtainMessage(NOTIFY_DEVICE_LOST, device);
sendMessageDelayed(message, NOTIFY_DEVICE_LOST_DELAY);
}
boolean hasNotifyDeviceLostMessages(BluetoothDevice device) {
return hasEqualMessages(NOTIFY_DEVICE_LOST, device);
}
void removeNotifyDeviceLostMessages(BluetoothDevice device) {
removeEqualMessages(NOTIFY_DEVICE_LOST, device);
}
}
private static String nameForBtState(int state) {
return nameForState(state) + "(" + state + ")";
}
private static String nameForBleScanCallbackType(int callbackType) {
final String name;
switch (callbackType) {
case CALLBACK_TYPE_ALL_MATCHES:
name = "ALL_MATCHES";
break;
case CALLBACK_TYPE_FIRST_MATCH:
name = "FIRST_MATCH";
break;
case CALLBACK_TYPE_MATCH_LOST:
name = "MATCH_LOST";
break;
default:
name = "Unknown";
}
return name + "(" + callbackType + ")";
}
private static String nameForBleScanErrorCode(int errorCode) {
final String name;
switch (errorCode) {
case SCAN_FAILED_ALREADY_STARTED:
name = "ALREADY_STARTED";
break;
case SCAN_FAILED_APPLICATION_REGISTRATION_FAILED:
name = "APPLICATION_REGISTRATION_FAILED";
break;
case SCAN_FAILED_INTERNAL_ERROR:
name = "INTERNAL_ERROR";
break;
case SCAN_FAILED_FEATURE_UNSUPPORTED:
name = "FEATURE_UNSUPPORTED";
break;
case SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES:
name = "OUT_OF_HARDWARE_RESOURCES";
break;
case SCAN_FAILED_SCANNING_TOO_FREQUENTLY:
name = "SCANNING_TOO_FREQUENTLY";
break;
default:
name = "Unknown";
}
return name + "(" + errorCode + ")";
}
private static final ScanSettings SCAN_SETTINGS = new ScanSettings.Builder()
.setCallbackType(CALLBACK_TYPE_FIRST_MATCH | CALLBACK_TYPE_MATCH_LOST)
.setScanMode(SCAN_MODE_LOW_POWER)
.build();
}