/* * Copyright 2019 Fitbit, Inc. All rights reserved. * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /* * Copyright (C) 2014 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.fitbit.bluetooth.fbgatt; import android.app.PendingIntent; import android.bluetooth.BluetoothGatt; import android.bluetooth.le.ScanCallback; import android.bluetooth.le.ScanFilter; import android.bluetooth.le.ScanResult; import android.bluetooth.le.ScanSettings; import android.os.Handler; import android.os.Looper; import android.os.Parcel; import android.os.Parcelable; import android.os.SystemClock; import android.os.WorkSource; import android.util.Log; import org.mockito.stubbing.Answer; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import timber.log.Timber; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** * This is not fitbit code, this is designed to mock, in as close fidelity as possible the google * android scanner from Android Pie. This still isn't flawless, but it should allow us to test * some of the harder scenarios to unit test by providing a mock adapter and a mock scanner that * is injectable from a mockito mock of the adapter, or just mocking the scanner if possible. * * This is a work-in-progress : 2019-07-08 */ public class MockLollipopScanner implements ScannerInterface { private static final String TAG = "MockLollipopScanner"; private static final boolean DBG = true; private static final boolean VDBG = false; private Handler mockHandler; private static ScheduledExecutorService singleThreadExecutor = Executors.newSingleThreadScheduledExecutor(); private Answer<Boolean> handlerPostAnswer = invocation -> { Long delay = 0L; if (invocation.getArguments().length > 1) { delay = invocation.getArgument(1); } Runnable msg = invocation.getArgument(0); if (msg != null) { singleThreadExecutor.schedule(msg, delay, TimeUnit.MILLISECONDS); } return true; }; public static class BluetoothAdapter { static boolean adapterOn = false; static IBluetoothGatt gatt = new IBluetoothGatt(); public static boolean getAdapterState() { return adapterOn; } public static void turnBluetoothOff() { adapterOn = false; } public static void turnBluetoothOn() { adapterOn = true; } public static MockLollipopScanner getBluetoothLeScanner() { return new MockLollipopScanner(); } public static IBluetoothGatt getGatt() { return gatt; } } public static final class ResultStorageDescriptor implements Parcelable { private int mType; private int mOffset; private int mLength; public int getType() { return mType; } public int getOffset() { return mOffset; } public int getLength() { return mLength; } /** * Constructor of {@link ResultStorageDescriptor} * * @param type Type of the data. * @param offset Offset from start of the advertise packet payload. * @param length Byte length of the data */ public ResultStorageDescriptor(int type, int offset, int length) { mType = type; mOffset = offset; mLength = length; } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeInt(mType); dest.writeInt(mOffset); dest.writeInt(mLength); } private ResultStorageDescriptor(Parcel in) { ReadFromParcel(in); } private void ReadFromParcel(Parcel in) { mType = in.readInt(); mOffset = in.readInt(); mLength = in.readInt(); } public static final Parcelable.Creator<ResultStorageDescriptor> CREATOR = new Creator<ResultStorageDescriptor>() { @Override public ResultStorageDescriptor createFromParcel(Parcel source) { return new ResultStorageDescriptor(source); } @Override public ResultStorageDescriptor[] newArray(int size) { return new ResultStorageDescriptor[size]; } }; } /** * Extra containing a list of ScanResults. It can have one or more results if there was no * error. In case of error, {@link #EXTRA_ERROR_CODE} will contain the error code and this * extra will not be available. */ public static final String EXTRA_LIST_SCAN_RESULT = "android.bluetooth.le.extra.LIST_SCAN_RESULT"; /** * Optional extra indicating the error code, if any. The error code will be one of the * SCAN_FAILED_* codes in {@link ScanCallback}. */ public static final String EXTRA_ERROR_CODE = "android.bluetooth.le.extra.ERROR_CODE"; /** * Optional extra indicating the callback type, which will be one of * CALLBACK_TYPE_* constants in {@link ScanSettings}. * * @see ScanCallback#onScanResult(int, ScanResult) */ public static final String EXTRA_CALLBACK_TYPE = "android.bluetooth.le.extra.CALLBACK_TYPE"; private Handler mHandler; private final Map<ScanCallback, BleScanCallbackWrapper> mLeScanClients; /** * Use {@link BluetoothAdapter#getBluetoothLeScanner()} instead. * */ public MockLollipopScanner() { Looper mockMainThreadLooper = mock(Looper.class); Thread mockMainThread = mock(Thread.class); when(mockMainThread.getName()).thenReturn("Irvin's mock thread"); when(mockMainThreadLooper.getThread()).thenReturn(mockMainThread); mockHandler = mock(Handler.class); doAnswer(handlerPostAnswer).when(mockHandler).post(any(Runnable.class)); doAnswer(handlerPostAnswer).when(mockHandler).postDelayed(any(Runnable.class), anyLong()); when(mockHandler.getLooper()).thenReturn(mockMainThreadLooper); mHandler = mockHandler; mLeScanClients = new HashMap<ScanCallback, BleScanCallbackWrapper>(); } /** * Start Bluetooth LE scan with default parameters and no filters. The scan results will be * delivered through {@code callback}. For unfiltered scans, scanning is stopped on screen * off to save power. Scanning is resumed when screen is turned on again. To avoid this, use * {@link #startScan(List, ScanSettings, ScanCallback)} with desired {@link ScanFilter}. * <p> * An app must hold * {@link android.Manifest.permission#ACCESS_COARSE_LOCATION ACCESS_COARSE_LOCATION} or * {@link android.Manifest.permission#ACCESS_FINE_LOCATION ACCESS_FINE_LOCATION} permission * in order to get results. * * @param callback Callback used to deliver scan results. */ @Override public void startScan(final ScanCallback callback) { startScan(null, new ScanSettings.Builder().build(), callback); } /** * Start Bluetooth LE scan. The scan results will be delivered through {@code callback}. * For unfiltered scans, scanning is stopped on screen off to save power. Scanning is * resumed when screen is turned on again. To avoid this, do filetered scanning by * using proper {@link ScanFilter}. * <p> * An app must hold * {@link android.Manifest.permission#ACCESS_COARSE_LOCATION ACCESS_COARSE_LOCATION} or * {@link android.Manifest.permission#ACCESS_FINE_LOCATION ACCESS_FINE_LOCATION} permission * in order to get results. * * @param filters {@link ScanFilter}s for finding exact BLE devices. * @param settings Settings for the scan. * @param callback Callback used to deliver scan results. */ @Override public void startScan(List<ScanFilter> filters, ScanSettings settings, final ScanCallback callback) { startScan(filters, settings, null, callback, /*callbackIntent=*/ null, null); } /** * Start Bluetooth LE scan using a {@link PendingIntent}. The scan results will be delivered via * the PendingIntent. Use this method of scanning if your process is not always running and it * should be started when scan results are available. * <p> * An app must hold * {@link android.Manifest.permission#ACCESS_COARSE_LOCATION ACCESS_COARSE_LOCATION} or * {@link android.Manifest.permission#ACCESS_FINE_LOCATION ACCESS_FINE_LOCATION} permission * in order to get results. * <p> * When the PendingIntent is delivered, the Intent passed to the receiver or activity * will contain one or more of the extras {@link #EXTRA_CALLBACK_TYPE}, * {@link #EXTRA_ERROR_CODE} and {@link #EXTRA_LIST_SCAN_RESULT} to indicate the result of * the scan. * * @param filters Optional list of ScanFilters for finding exact BLE devices. * @param settings Optional settings for the scan. * @param callbackIntent The PendingIntent to deliver the result to. * @return Returns 0 for success or an error code from {@link ScanCallback} if the scan request * could not be sent. * @see #stopScan(PendingIntent) */ public int startScan(@Nullable List<ScanFilter> filters, @Nullable ScanSettings settings, @NonNull PendingIntent callbackIntent) { return startScan(filters, settings != null ? settings : new ScanSettings.Builder().build(), null, null, callbackIntent, null); } private int startScan(List<ScanFilter> filters, ScanSettings settings, final WorkSource workSource, final ScanCallback callback, final PendingIntent callbackIntent, List<List<ResultStorageDescriptor>> resultStorages) { if (!BluetoothAdapter.getAdapterState()) { throw new IllegalStateException("Bluetooth off"); } if (callback == null && callbackIntent == null) { throw new IllegalArgumentException("callback is null"); } if (settings == null) { throw new IllegalArgumentException("settings is null"); } synchronized (mLeScanClients) { if (callback != null && mLeScanClients.containsKey(callback)) { return postCallbackErrorOrReturn(callback, ScanCallback.SCAN_FAILED_ALREADY_STARTED); } IBluetoothGatt gatt = new IBluetoothGatt(); if (!isSettingsConfigAllowedForScan(settings)) { return postCallbackErrorOrReturn(callback, ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED); } if (!isHardwareResourcesAvailableForScan(settings)) { Timber.e("Actually scan failed out of hardware resources"); return postCallbackErrorOrReturn(callback, 5); } if (!isSettingsAndFilterComboAllowed(settings, filters)) { return postCallbackErrorOrReturn(callback, ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED); } if (callback != null) { BleScanCallbackWrapper wrapper = new BleScanCallbackWrapper(gatt, filters, settings, workSource, callback, resultStorages); wrapper.startRegistration(); } else { gatt.startScanForIntent(callbackIntent, settings, filters, "com.fitbit.bluetooth.fbgatt"); } } return 0; } /** * Stops an ongoing Bluetooth LE scan. * * @param callback */ public void stopScan(ScanCallback callback) { if (!BluetoothAdapter.getAdapterState()) { throw new IllegalStateException("Bluetooth off"); } synchronized (mLeScanClients) { BleScanCallbackWrapper wrapper = mLeScanClients.remove(callback); if (wrapper == null) { if (DBG) Log.d(TAG, "could not find callback wrapper"); return; } wrapper.stopLeScan(); } } /** * Stops an ongoing Bluetooth LE scan started using a PendingIntent. * * @param callbackIntent The PendingIntent that was used to start the scan. * @see #startScan(List, ScanSettings, PendingIntent) */ public void stopScan(PendingIntent callbackIntent) { if (!BluetoothAdapter.getAdapterState()) { throw new IllegalStateException("Bluetooth off"); } IBluetoothGatt gatt = BluetoothAdapter.getGatt(); gatt.stopScanForIntent(callbackIntent, "com.fitbit.bluetooth.fbgatt"); } /** * Flush pending batch scan results stored in Bluetooth controller. This will return Bluetooth * LE scan results batched on bluetooth controller. Returns immediately, batch scan results data * will be delivered through the {@code callback}. * * @param callback Callback of the Bluetooth LE Scan, it has to be the same instance as the one * used to start scan. */ public void flushPendingScanResults(ScanCallback callback) { if (!BluetoothAdapter.getAdapterState()) { throw new IllegalStateException("Bluetooth off"); } if (callback == null) { throw new IllegalArgumentException("callback cannot be null!"); } synchronized (mLeScanClients) { BleScanCallbackWrapper wrapper = mLeScanClients.get(callback); if (wrapper == null) { return; } wrapper.flushPendingBatchResults(); } } @Override public boolean isBluetoothEnabled() { return BluetoothAdapter.getAdapterState(); } /** * Cleans up scan clients. Should be called when bluetooth is down. */ public void cleanup() { mLeScanClients.clear(); } private static class IBluetoothGatt { private static final long TIME_BETWEEN_RESULTS = 1000; HashMap<BleScanCallbackWrapper, WorkSource> scanners = new HashMap<>(); HashMap<Integer, ScanState> startedScanners = new HashMap<>(); private MockScanResultProvider provider = new MockScanResultProvider(5, -145, -11); private ScanState currentScanState = ScanState.NOT_SCANNING; private Runnable resultsRunnable = new Runnable() { @Override public void run() { for (BleScanCallbackWrapper wrapper : IBluetoothGatt.this.scanners.keySet()) { List<ScanResult> results = provider.getAllResults(); for (ScanResult result : results) { wrapper.onScanResult(result); } } } }; private Runnable intentDeliveryRunnable = new Runnable() { @Override public void run() { for (BleScanCallbackWrapper wrapper : IBluetoothGatt.this.scanners.keySet()) { List<ScanResult> results = provider.getAllResults(); for (ScanResult result : results) { wrapper.onScanResult(result); } } } }; public void startScanForIntent(PendingIntent callbackIntent, ScanSettings settings, List<ScanFilter> filters, String packageName) { singleThreadExecutor.schedule(resultsRunnable, TIME_BETWEEN_RESULTS, TimeUnit.MILLISECONDS); if (settings.getScanMode() == ScanSettings.SCAN_MODE_LOW_LATENCY) { currentScanState = ScanState.INTENT_SCANNING; } else if (settings.getScanMode() == ScanSettings.SCAN_MODE_BALANCED) { currentScanState = ScanState.INTENT_SCANNING; } else if (settings.getScanMode() == ScanSettings.SCAN_MODE_LOW_POWER) { currentScanState = ScanState.INTENT_SCANNING; } } public void stopScanForIntent(PendingIntent callbackIntent, String packageName) { /* Set<Integer> keys = startedScanners.keySet(); for (Integer key : keys) { if (key == scanId) { Timber.v("Cancelling scanner with id: %d", scanId); startedScanners.remove(scanId); } } */ } public void unregisterClient(int scannerId) { Set<BleScanCallbackWrapper> keys = scanners.keySet(); for (BleScanCallbackWrapper key : keys) { if (key.mScannerId == scannerId) { Timber.v("Cancelling scanner with id: %d", scannerId); scanners.remove(key); } } } public void startScan(int mScannerId, ScanSettings mSettings, List<ScanFilter> mFilters, List<List<ResultStorageDescriptor>> mResultStorages, String packageName) { if (mSettings.getScanMode() == ScanSettings.SCAN_MODE_LOW_LATENCY) { currentScanState = ScanState.LOW_LATENCY; } else if (mSettings.getScanMode() == ScanSettings.SCAN_MODE_BALANCED) { currentScanState = ScanState.BALANCED_LATENCY; } else if (mSettings.getScanMode() == ScanSettings.SCAN_MODE_LOW_POWER) { currentScanState = ScanState.HIGH_LATENCY; } Timber.v("Current scan state: %s", currentScanState); SystemClock.sleep(TIME_BETWEEN_RESULTS); resultsRunnable.run(); } private enum ScanState { NOT_SCANNING, HIGH_LATENCY, BALANCED_LATENCY, LOW_LATENCY, INTENT_SCANNING; } public void registerScanner(BleScanCallbackWrapper scanWrapper, WorkSource workSource) { scanners.put(scanWrapper, workSource); int i = 0; for (BleScanCallbackWrapper wrapper : scanners.keySet()) { if (wrapper.equals(scanWrapper)) { break; } i++; } scanWrapper.onScannerRegistered(0, i); } public void stopScan(int scanId) { Set<Integer> keys = startedScanners.keySet(); for (Integer key : keys) { if (key == scanId) { Timber.v("Cancelling scanner with id: %d", scanId); startedScanners.remove(scanId); } } } public void unregisterScanner(int scannerId) { Set<BleScanCallbackWrapper> keys = scanners.keySet(); for (BleScanCallbackWrapper key : keys) { if (key.mScannerId == scannerId) { Timber.v("Unregistering scanner with id: %d", scannerId); scanners.remove(scannerId); } } } public void flushPendingBatchResults(int scannerId) { List<ScanResult> results = provider.getAllResults(); Set<BleScanCallbackWrapper> keys = scanners.keySet(); BleScanCallbackWrapper wrapper = null; for (BleScanCallbackWrapper key : keys) { if (key.mScannerId == scannerId) { wrapper = key; break; } } if (wrapper != null) { wrapper.onBatchScanResults(results); } } } /** * Bluetooth GATT interface callbacks */ private class BleScanCallbackWrapper { private static final int REGISTRATION_CALLBACK_TIMEOUT_MILLIS = 2000; private final ScanCallback mScanCallback; private final List<ScanFilter> mFilters; private final WorkSource mWorkSource; private ScanSettings mSettings; private IBluetoothGatt mBluetoothGatt; private List<List<ResultStorageDescriptor>> mResultStorages; // mLeHandle 0: not registered // -2: registration failed because app is scanning to frequently // -1: scan stopped or registration failed // > 0: registered and scan started private int mScannerId; public BleScanCallbackWrapper(IBluetoothGatt bluetoothGatt, List<ScanFilter> filters, ScanSettings settings, WorkSource workSource, ScanCallback scanCallback, List<List<ResultStorageDescriptor>> resultStorages) { mBluetoothGatt = bluetoothGatt; mFilters = filters; mSettings = settings; mWorkSource = workSource; mScanCallback = scanCallback; mScannerId = 0; mResultStorages = resultStorages; } public void startRegistration() { synchronized (this) { // Scan stopped. if (mScannerId == -1 || mScannerId == -2) return; try { mBluetoothGatt.registerScanner(this, mWorkSource); wait(REGISTRATION_CALLBACK_TIMEOUT_MILLIS); } catch (InterruptedException e) { Timber.tag(TAG).e(e, "application registration exception"); postCallbackError(mScanCallback, ScanCallback.SCAN_FAILED_INTERNAL_ERROR); } if (mScannerId > 0) { mLeScanClients.put(mScanCallback, this); } else { // Registration timed out or got exception, reset RscannerId to -1 so no // subsequent operations can proceed. if (mScannerId == 0) mScannerId = -1; // If scanning too frequently, don't report anything to the app. if (mScannerId == -2) return; postCallbackError(mScanCallback, ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED); } } } public void stopLeScan() { synchronized (this) { if (mScannerId <= 0) { Log.e(TAG, "Error state, mLeHandle: " + mScannerId); return; } mBluetoothGatt.stopScan(mScannerId); mBluetoothGatt.unregisterScanner(mScannerId); mScannerId = -1; } } void flushPendingBatchResults() { synchronized (this) { if (mScannerId <= 0) { Log.e(TAG, "Error state, mLeHandle: " + mScannerId); return; } mBluetoothGatt.flushPendingBatchResults(mScannerId); } } /** * Application interface registered - app is ready to go */ public void onScannerRegistered(int status, int scannerId) { Log.d(TAG, "onScannerRegistered() - status=" + status + " scannerId=" + scannerId + " mScannerId=" + mScannerId); synchronized (this) { if (status == BluetoothGatt.GATT_SUCCESS) { if (mScannerId == -1) { // Registration succeeds after timeout, unregister client. mBluetoothGatt.unregisterClient(scannerId); } else { mScannerId = scannerId; mBluetoothGatt.startScan(mScannerId, mSettings, mFilters, mResultStorages, "com.fitbit.bluetooth.fbgatt"); } } else if (status == 0x06) { // applicaiton was scanning too frequently mScannerId = -2; } else { // registration failed mScannerId = -1; } notifyAll(); } } /** * Callback reporting an LE scan result. */ public void onScanResult(final ScanResult scanResult) { if (VDBG) Log.d(TAG, "onScanResult() - " + scanResult.toString()); // Check null in case the scan has been stopped synchronized (this) { if (mScannerId <= 0) return; } Handler handler = mHandler; handler.post(new Runnable() { @Override public void run() { mScanCallback.onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES, scanResult); } }); } public void onBatchScanResults(final List<ScanResult> results) { Handler handler = mHandler; handler.post(new Runnable() { @Override public void run() { mScanCallback.onBatchScanResults(results); } }); } public void onFoundOrLost(final boolean onFound, final ScanResult scanResult) { if (VDBG) { Log.d(TAG, "onFoundOrLost() - onFound = " + onFound + " " + scanResult.toString()); } // Check null in case the scan has been stopped synchronized (this) { if (mScannerId <= 0) { return; } } Handler handler = mHandler; handler.post(new Runnable() { @Override public void run() { if (onFound) { mScanCallback.onScanResult(ScanSettings.CALLBACK_TYPE_FIRST_MATCH, scanResult); } else { mScanCallback.onScanResult(ScanSettings.CALLBACK_TYPE_MATCH_LOST, scanResult); } } }); } public void onScanManagerErrorCallback(final int errorCode) { if (VDBG) { Log.d(TAG, "onScanManagerErrorCallback() - errorCode = " + errorCode); } synchronized (this) { if (mScannerId <= 0) { return; } } postCallbackError(mScanCallback, errorCode); } } private int postCallbackErrorOrReturn(final ScanCallback callback, final int errorCode) { if (callback == null) { return errorCode; } else { postCallbackError(callback, errorCode); return 0; } } private void postCallbackError(final ScanCallback callback, final int errorCode) { mHandler.post(new Runnable() { @Override public void run() { callback.onScanFailed(errorCode); } }); } private boolean isSettingsConfigAllowedForScan(ScanSettings settings) { final int callbackType = settings.getCallbackType(); // Only support regular scan if no offloaded filter support. if (callbackType == ScanSettings.CALLBACK_TYPE_ALL_MATCHES && settings.getReportDelayMillis() == 0) { return true; } return false; } private static final ScanFilter EMPTY = new ScanFilter.Builder().build(); private boolean isSettingsAndFilterComboAllowed(ScanSettings settings, List<ScanFilter> filterList) { final int callbackType = settings.getCallbackType(); // If onlost/onfound is requested, a non-empty filter is expected if ((callbackType & (ScanSettings.CALLBACK_TYPE_FIRST_MATCH | ScanSettings.CALLBACK_TYPE_MATCH_LOST)) != 0) { if (filterList == null) { return false; } for (ScanFilter filter : filterList) { if (filter.equals(EMPTY)) { return false; } } } return true; } private boolean isHardwareResourcesAvailableForScan(ScanSettings settings) { Timber.v("Settings: %s", settings); return true; } }