diff --git a/services/companion/java/com/android/server/companion/presence/BleCompanionDeviceScanner.java b/services/companion/java/com/android/server/companion/presence/BleCompanionDeviceScanner.java new file mode 100644 index 0000000000000..0eb6b8d247688 --- /dev/null +++ b/services/companion/java/com/android/server/companion/presence/BleCompanionDeviceScanner.java @@ -0,0 +1,436 @@ +/* + * 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.STATE_BLE_ON; +import static android.bluetooth.BluetoothAdapter.STATE_ON; +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.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 boolean DEBUG = false; + private static final String TAG = "CompanionDevice_PresenceMonitor_BLE"; + + /** + * 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 = isBleAvailable(); + 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; + } + } + + /** + * A duplicate of {@code BluetoothAdapter.getLeAccess()} method which has the package-private + * access level, so it's not accessible from here. + */ + private boolean isBleAvailable() { + final int state = mBtAdapter.getLeState(); + if (DEBUG) Log.d(TAG, "getLeAccess() state=" + nameForBtState(state)); + return state == STATE_ON || state == STATE_BLE_ON; + } + + @MainThread + private void startScan() { + enforceInitialized(); + // This method should not be called if scan is already in progress. + if (mScanning) throw new IllegalStateException("Scan is already in progress."); + // Neither should this method be called if the adapter is not available. + if (mBleScanner == null) throw new IllegalStateException("BLE is not available."); + + if (DEBUG) Log.i(TAG, "startScan()"); + + // Collect MAC addresses from all associations. + final Set macAddresses = new HashSet<>(); + for (AssociationInfo association : mAssociationStore.getAssociations()) { + // 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 filters = new ArrayList<>(macAddresses.size()); + for (String macAddress : macAddresses) { + final ScanFilter filter = new ScanFilter.Builder() + .setDeviceAddress(macAddress) + .build(); + filters.add(filter); + } + + mBleScanner.startScan(filters, SCAN_SETTINGS, mScanCallback); + mScanning = true; + } + + private void stopScanIfNeeded() { + enforceInitialized(); + + if (DEBUG) Log.i(TAG, "stopScan()"); + if (!mScanning) { + Log.d(TAG, " > not scanning."); + return; + } + + mBleScanner.stopScan(mScanCallback); + mScanning = false; + } + + @MainThread + private void notifyDeviceFound(@NonNull BluetoothDevice device) { + if (DEBUG) Log.i(TAG, "notifyDevice_Found()" + btDeviceToString(device)); + + final List 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 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 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(); +} diff --git a/services/companion/java/com/android/server/companion/presence/BluetoothCompanionDeviceConnectionListener.java b/services/companion/java/com/android/server/companion/presence/BluetoothCompanionDeviceConnectionListener.java index a4fa1c12b29bb..dbe866b374f12 100644 --- a/services/companion/java/com/android/server/companion/presence/BluetoothCompanionDeviceConnectionListener.java +++ b/services/companion/java/com/android/server/companion/presence/BluetoothCompanionDeviceConnectionListener.java @@ -16,6 +16,8 @@ package com.android.server.companion.presence; +import static com.android.server.companion.presence.Utils.btDeviceToString; + import android.annotation.NonNull; import android.annotation.SuppressLint; import android.bluetooth.BluetoothAdapter; @@ -71,11 +73,11 @@ class BluetoothCompanionDeviceConnectionListener */ @Override public void onDeviceConnected(@NonNull BluetoothDevice device) { - if (DEBUG) Log.i(TAG, "onDevice_Connected() " + toString(device)); + if (DEBUG) Log.i(TAG, "onDevice_Connected() " + btDeviceToString(device)); final MacAddress macAddress = MacAddress.fromString(device.getAddress()); if (mAllConnectedDevices.put(macAddress, device) != null) { - if (DEBUG) Log.w(TAG, "Device " + toString(device) + " is already connected."); + if (DEBUG) Log.w(TAG, "Device " + btDeviceToString(device) + " is already connected."); return; } @@ -91,13 +93,15 @@ class BluetoothCompanionDeviceConnectionListener public void onDeviceDisconnected(@NonNull BluetoothDevice device, @DisconnectReason int reason) { if (DEBUG) { - Log.i(TAG, "onDevice_Disconnected() " + toString(device)); + Log.i(TAG, "onDevice_Disconnected() " + btDeviceToString(device)); Log.d(TAG, " reason=" + disconnectReasonText(reason)); } final MacAddress macAddress = MacAddress.fromString(device.getAddress()); if (mAllConnectedDevices.remove(macAddress) == null) { - if (DEBUG) Log.w(TAG, "The device wasn't tracked as connected " + toString(device)); + if (DEBUG) { + Log.w(TAG, "The device wasn't tracked as connected " + btDeviceToString(device)); + } return; } @@ -109,7 +113,7 @@ class BluetoothCompanionDeviceConnectionListener mAssociationStore.getAssociationsByAddress(device.getAddress()); if (DEBUG) { - Log.d(TAG, "onDevice_ConnectivityChanged() " + toString(device) + Log.d(TAG, "onDevice_ConnectivityChanged() " + btDeviceToString(device) + " connected=" + connected); if (associations.isEmpty()) { Log.d(TAG, " > No CDM associations"); @@ -137,6 +141,12 @@ class BluetoothCompanionDeviceConnectionListener } } + @Override + public void onAssociationRemoved(AssociationInfo association) { + // Intentionally do nothing: CompanionDevicePresenceMonitor will do all the bookkeeping + // required. + } + @Override public void onAssociationUpdated(AssociationInfo association, boolean addressChanged) { if (DEBUG) { @@ -153,23 +163,4 @@ class BluetoothCompanionDeviceConnectionListener // This will be implemented when CDM support updating addresses. throw new IllegalArgumentException("Address changes are not supported."); } - - private static String toString(@NonNull BluetoothDevice btDevice) { - final StringBuilder sb = new StringBuilder(btDevice.getAddress()); - - sb.append(" [name="); - final String name = btDevice.getName(); - if (name != null) { - sb.append('\'').append(name).append('\''); - } else { - sb.append("null"); - } - - final String alias = btDevice.getAlias(); - if (alias != null) { - sb.append(", alias='").append(alias).append("'"); - } - - return sb.append(']').toString(); - } } diff --git a/services/companion/java/com/android/server/companion/presence/Utils.java b/services/companion/java/com/android/server/companion/presence/Utils.java new file mode 100644 index 0000000000000..583b443c8cb7b --- /dev/null +++ b/services/companion/java/com/android/server/companion/presence/Utils.java @@ -0,0 +1,49 @@ +/* + * 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 android.annotation.NonNull; +import android.bluetooth.BluetoothDevice; + +/** Utilities for working with Bluetooth and BLE devices. */ +class Utils { + + /** + * @return short String representation of {@link BluetoothDevice}. + */ + static String btDeviceToString(@NonNull BluetoothDevice btDevice) { + final StringBuilder sb = new StringBuilder(btDevice.getAddress()); + + sb.append(" [name="); + final String name = btDevice.getName(); + if (name != null) { + sb.append('\'').append(name).append('\''); + } else { + sb.append("null"); + } + + final String alias = btDevice.getAlias(); + if (alias != null) { + sb.append(", alias='").append(alias).append("'"); + } + + return sb.append(']').toString(); + } + + private Utils() { + } +}