/* * 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/. */ package com.fitbit.bluetooth.fbgatt; import com.fitbit.bluetooth.fbgatt.util.GattUtils; import android.annotation.TargetApi; import android.app.PendingIntent; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.le.ScanCallback; import android.bluetooth.le.ScanFilter; import android.bluetooth.le.ScanResult; import android.bluetooth.le.ScanSettings; import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Handler; import android.os.ParcelUuid; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import timber.log.Timber; 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 com.fitbit.bluetooth.fbgatt.FitbitGatt.atLeastSDK; /** * A Scanner for Bluetooth LE Devices */ @TargetApi(21) class PeripheralScanner { final static long SCAN_DURATION = TimeUnit.SECONDS.toMillis(120); private final static long SCAN_INTERVAL = SCAN_DURATION * 2; final static long SCAN_TOO_MUCH_WARN_INTERVAL = TimeUnit.SECONDS.toMillis(30); final static long TEST_SCAN_DURATION = TimeUnit.SECONDS.toMillis(2); final static long TEST_SCAN_INTERVAL = TEST_SCAN_DURATION * 2; private final static int MAX_BACKOFF_MULTIPLIER = 2; // should always correspond to roughly 16 minutes worst case static final int BACKGROUND_SCAN_REQUEST_CODE = 21436; static final String SCANNED_DEVICE_ACTION = "com.fitbit.bluetooth.fbgatt.ScannedDevice"; private static final int MAX_SCANS_ALLOWED_PER_30_SECONDS = 4; private static final int DEFAULT_SCAN_BACKOFF_MULTIPLIER = 1; Handler mHandler; @VisibleForTesting final Runnable scanTimeoutRunnable = new ScanTimeoutRunnable(); private final Runnable periodicRunnable = new PeriodicScanRunnable(); private ScannerInterface scanner; private int scanMode = ScanSettings.SCAN_MODE_LOW_POWER; private boolean stopPeriodicalScan; private int scanBackoffMultiplier = 1; private int minRssi = Integer.MIN_VALUE; private final ArrayList<ScanFilter> scanFilters = new ArrayList<>(1); private AtomicInteger scanCount; AtomicBoolean isScanning; private AtomicBoolean pendingIntentIsScanning; AtomicBoolean periodicalScanEnabled; private ScanCallback callback; private TrackerScannerListener listener; private GattUtils bleUtils; private boolean instrumentationTestMode; private Map<String, BluetoothDevice> foundDevices = new HashMap<>(); private boolean resetScanBackoff; private PendingIntent backgroundIntentBasedScanIntent; interface TrackerScannerListener { void onScanStatusChanged(boolean isScanning); void onFitbitDeviceFound(FitbitBluetoothDevice device); void onPendingIntentScanStatusChanged(boolean isScanning); } private Runnable resetScanCounter = new Runnable() { @Override public void run() { long scannerTime = getScannerDuration(); Timber.v("Resetting scan too much counter, %s ms have gone by.", scannerTime); scanCount.set(0); mHandler.postDelayed(resetScanCounter, SCAN_TOO_MUCH_WARN_INTERVAL); } }; PeripheralScanner(Context context, @NonNull TrackerScannerListener listener) { this.listener = listener; isScanning = new AtomicBoolean(false); pendingIntentIsScanning = new AtomicBoolean(false); periodicalScanEnabled = new AtomicBoolean(false); // you can't call start / stop more than 5 times in 30 seconds, if you do // the system will convert your scan to opportunistic so you'll have to wait for something // else to scan and back off your scan interval to 3120ms. It will also silently // fail any new scans, so we'll track it with this handy counter. scanCount = new AtomicInteger(0); // we can use the main looper because the scan command doesn't block mHandler = new Handler(context.getMainLooper()); // we can just run this every 30s, if the caller doesn't do anything wrong it should never // exceed five ... once it gets to 4 don't let the user start another one mHandler.postDelayed(resetScanCounter, SCAN_TOO_MUCH_WARN_INTERVAL); bleUtils = new GattUtils(); scanner = new BitgattLeScanner(context); callback = new ScanCallback() { @Override public void onScanResult(int callbackType, ScanResult result) { BluetoothDevice device = result.getDevice(); FitbitBluetoothDevice dev = new FitbitBluetoothDevice(result.getDevice()); if (minRssi == Integer.MIN_VALUE || minRssi < result.getRssi()) { if (!foundDevices.containsKey(device.getAddress())) { foundDevices.put(device.getAddress(), device); resetScanBackoff = true; } dev.setRssi(result.getRssi()); dev.origin = FitbitBluetoothDevice.DeviceOrigin.SCANNED; dev.setScanRecord(result.getScanRecord()); listener.onFitbitDeviceFound(dev); } else { Timber.v("Scanned device %s below RSSI threshold", dev); } } @Override public void onBatchScanResults(List<ScanResult> results) { for (ScanResult result : results) { FitbitBluetoothDevice dev = new FitbitBluetoothDevice(result.getDevice()); if (minRssi == Integer.MIN_VALUE || minRssi < result.getRssi()) { dev.origin = FitbitBluetoothDevice.DeviceOrigin.SCANNED; dev.setRssi(result.getRssi()); dev.setScanRecord(result.getScanRecord()); listener.onFitbitDeviceFound(dev); } else { Timber.v("Scanned device %s below RSSI threshold", dev); } } } @Override public void onScanFailed(int errorCode) { super.onScanFailed(errorCode); Timber.w("onScanFailed %s", ScanFailure.getFailureForReason(errorCode)); isScanning.set(false); listener.onScanStatusChanged(isScanning.get()); if (!FitbitGatt.getInstance().isBluetoothOn()) { Timber.v("Bluetooth was off, releasing the scanner"); } } }; } private long getScannerDuration() { return instrumentationTestMode ? TEST_SCAN_DURATION : SCAN_DURATION; } private long getScannerInterval() { return instrumentationTestMode ? TEST_SCAN_INTERVAL : SCAN_INTERVAL; } void setInstrumentationTestMode(boolean instrumentationTestMode) { this.instrumentationTestMode = instrumentationTestMode; } void setIsPendingIntentScanning(boolean isStillPendingIntentScanning) { this.pendingIntentIsScanning.set(isStillPendingIntentScanning); } void setHandler(Handler mockHandler) { this.mHandler = mockHandler; } /** * Stops the scans and releases any resources. */ void onDestroy(Context context) { cancelPeriodicalScan(context); cancelScan(context); } @VisibleForTesting(otherwise = VisibleForTesting.NONE) void injectMockScanner(ScannerInterface scanner) { this.scanner = scanner; } /** * Can be used if directly managing scan filters is required * * @param scanFilters The scan filters to be applied to the scan */ void setScanFilters(List<ScanFilter> scanFilters) { synchronized (this.scanFilters) { this.scanFilters.clear(); this.scanFilters.addAll(scanFilters); } } /** * This will start periodic scan with low power mode, please note that this will return false * if there is already a high-priority scan going on. * * @return true if scan started, false if not */ synchronized boolean startPeriodicScan(@Nullable Context context) { if (context == null) { Timber.v("Can't start a high priority scan with a null context"); return false; } if (stopPeriodicalScan || isScanning.get()) { Timber.v("Not starting periodical scan: isScanning: %b, was stopPeriodicalScan requested? %b", isScanning.get(), stopPeriodicalScan); return false; } Timber.d("Start Periodic Scan"); scanMode = ScanSettings.SCAN_MODE_LOW_POWER; scanBackoffMultiplier = DEFAULT_SCAN_BACKOFF_MULTIPLIER; periodicalScanEnabled.set(true); return startScan(context); } /** * This will start a high priority scan * * @return true if scan started, false if not */ synchronized boolean startHighPriorityScan(@Nullable Context context) { if (context == null) { Timber.v("Can't start a high priority scan with a null context"); return false; } if (isScanning.get()) { cancelScan(context); } Timber.d("Start High priority Scan"); scanMode = ScanSettings.SCAN_MODE_LOW_LATENCY; return startScan(context); } /** * This will cancel any current scans and will not stop the periodic ones. */ synchronized void cancelScan(@Nullable Context context) { if (context == null) { Timber.v("Can't cancel the scan with a null context"); return; } Timber.d("StopScanning requested "); stopScan(context); mHandler.removeCallbacks(scanTimeoutRunnable); mHandler.removeCallbacks(periodicRunnable); } /** * This will stop any high priority scans that are in progress, but will not cancel any * periodical scans that are already in progress. If there are none, this is equivalent * to cancelScan(Context context). */ synchronized void cancelHighPriorityScan(@Nullable Context context) { if (context == null) { Timber.v("Can't cancel the scan with a null context"); return; } Timber.d("Stop high-priority scan requested"); // if there was a periodical scan enabled we will want to make sure it is restarted after // we stop the scan if (periodicalScanEnabled.get()) { stopScan(context); mHandler.removeCallbacks(scanTimeoutRunnable); mHandler.removeCallbacks(periodicRunnable); mHandler.post(periodicRunnable); } else { cancelScan(context); } } /** * This will stop any future periodical scans that are happening, but will not affect any scans * that are already in progress, high-priority or otherwise. */ synchronized void cancelPeriodicalScan(@Nullable Context context) { if (context == null) { Timber.v("Can't cancel the scan with a null context"); return; } // set scan multipliers back to default scanBackoffMultiplier = DEFAULT_SCAN_BACKOFF_MULTIPLIER; periodicalScanEnabled.set(false); mHandler.removeCallbacks(scanTimeoutRunnable); mHandler.removeCallbacks(periodicRunnable); // if we are presently actively scanning we will let that scan run it's course and then // let it call onScanStatusChanged, otherwise we will fire it from here so the caller knows if (!isScanning()) { listener.onScanStatusChanged(isScanning()); } } /** * Set Filters by device name, will clear and reset the filters such that the next periodical * or high priority scan will pick them up * * @param deviceNameFilters The bluetooth peripheral names to limit the filter for */ void setDeviceNameFilters(List<String> deviceNameFilters) { synchronized (scanFilters) { scanFilters.clear(); if (deviceNameFilters != null) { for (String name : deviceNameFilters) { scanFilters.add(new ScanFilter.Builder().setDeviceName(name).build()); } } } } /** * Will return a shallow copy of the scan filters, copy because we don't want something * outside of the scanner changing the actual contents of the array inadvertently. * * @return Shallow copy of the scan filters */ ArrayList<ScanFilter> getScanFilters() { return new ArrayList<>(scanFilters); } /** * Set filters by UUID, will clear and reset the filters such that the next periodical * or high priority scan will pick them up * * @param uuidFilters The list of service UUIDs to use in the filter */ void setServiceUuidFilters(List<ParcelUuid> uuidFilters) { synchronized (scanFilters) { scanFilters.clear(); if (uuidFilters != null) { for (ParcelUuid uuid : uuidFilters) { if (uuid != null) { scanFilters.add(new ScanFilter.Builder().setServiceUuid(uuid).build()); } } } } } /** * If you want to only receive callbacks for devices with a rssi higher than a given value * set the rssi filter * * @param minRssi The minimum RSSI to be returned */ void addRssiFilter(int minRssi) { this.minRssi = minRssi; } /** * Will add a new device name to the filter list such that the next scan will find devices * with this name, will not affect the currently in progress scan * * @param deviceName The remote bluetooth name of the device for which you are searching */ void addDeviceNameFilter(String deviceName) { ScanFilter scanFilter = new ScanFilter.Builder().setDeviceName(deviceName).build(); synchronized (scanFilters) { scanFilters.add(scanFilter); } } /** * Will add the service UUID with a given mask to find multiple devices that conform to a uuid * service pattern in the advertisement * * @param service The service parceluuid * @param mask The parceluuid service mask */ void addServiceUUIDWithMask(ParcelUuid service, ParcelUuid mask) { ScanFilter scanFilter = new ScanFilter.Builder().setServiceUuid(service, mask).build(); synchronized (scanFilters) { scanFilters.add(scanFilter); } } /** * Add a filter for the scanner based on the service data * * @param serviceUUID The parcel uuid for the service * @param serviceData The actual service data * @param serviceDataMask The service data mask */ void addFilterUsingServiceData(ParcelUuid serviceUUID, byte[] serviceData, byte[] serviceDataMask) { ScanFilter scanFilter = new ScanFilter.Builder().setServiceData(serviceUUID, serviceData, serviceDataMask).build(); synchronized (scanFilters) { scanFilters.add(scanFilter); } } /** * Add scanner filter on device address. * * @param deviceAddress he device Bluetooth address for the filter. It needs to be in the * format of "01:02:03:AB:CD:EF". The device address can be validated using * {@link BluetoothAdapter#checkBluetoothAddress}. */ void addDeviceAddressFilter(String deviceAddress) { ScanFilter scanFilter = new ScanFilter.Builder().setDeviceAddress(deviceAddress).build(); synchronized (scanFilters) { scanFilters.add(scanFilter); } } /** * Will remove the cached scan filters, will not cancel the scan or affect a running scan * in any way */ void resetFilters() { synchronized (scanFilters) { scanFilters.clear(); } } /** * To determine if there is an active scan going on right now * * @return True if this class is actively scanning */ public boolean isScanning() { return isScanning.get(); } /** * To determine if there is a pending intent scan going right now * * @return True if there is a pending intent scan occurring */ @SuppressWarnings("WeakerAccess") // API Method public boolean isPendingIntentScanning() { return pendingIntentIsScanning.get(); } /** * To determine if there is a periodical scan enabled * * @return True if there is a periodical scan enabled */ @SuppressWarnings("WeakerAccess") // API Method public boolean isPeriodicalScanEnabled() { return periodicalScanEnabled.get(); } /** * Will cancel a running system managed pending intent based background scan */ synchronized void cancelPendingIntentBasedBackgroundScan() { if (atLeastSDK(Build.VERSION_CODES.O)) { Context appContext = FitbitGatt.getInstance().getAppContext(); if (!scanner.isBluetoothEnabled()) { Timber.v("No scanners can be started while bluetooth is off"); // must release the scanner here so that the system can clean it up since // we can't access it with bt off synchronized (PeripheralScanner.class) { scanner = new BitgattLeScanner(appContext); } boolean oldValue = pendingIntentIsScanning.getAndSet(false); Timber.v("Stopping scan, changing from %b to %b", oldValue, false); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); return; } if (backgroundIntentBasedScanIntent != null) { scanner.stopScan(backgroundIntentBasedScanIntent); } else { Timber.i("No existing pending intent, cancelling using system intent just in case ..."); scanner.stopScan(getSystemPendingIntent(appContext)); } stopPeriodicalScan = false; backgroundIntentBasedScanIntent = null; boolean oldValue = pendingIntentIsScanning.getAndSet(false); Timber.v("Stopping scan, changing from %b to %b", oldValue, false); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); } else { Timber.v("This type of scan can not be executed or stopped if not API 26+"); } } /** * Will start a background scan that will continue to run even if our process is killed. This * will internally handle the result of particular intent based scan results and deliver * connection callbacks when items are found. In order to start a pending intent based scan you will * need to stop any existing high-priority scan in order to enable the pending intent based scan, the intended * use of this API is for scanning while your application is in the background. When you * come into the foreground, you should cancel the background scan * with {@link PeripheralScanner#cancelPendingIntentBasedBackgroundScan()} unless you want for * the background scan to continue. Be advised that this might result in multiple callbacks to * {@link FitbitGatt.FitbitGattCallback#onBluetoothPeripheralDiscovered(GattConnection)}. * <p> * This background scan will be auto cancelled by the Android operating system in a way that we * can not control if BT is turned off or if the phone is rebooted. This is a function of the * pending intent scan Android API. * <p> * WARNING!!!! Using this with scan filters that are empty is extremely dangerous and is frowned upon * your application will potentially get hundreds of intent callbacks every second. Please do * not use this to get around the scanfilter empty check. Also, this call will consume a gatt_if * so Bitgatt will not let you have more than one scan running at a time, if you start a new scan * it will stop the previous one. * * @param scanFilters The specific scan filters for which to be called back * @param context The Android context for creating the pending intent * @return true if the pending intent scan is started, false if not */ synchronized boolean startPendingIntentBasedBackgroundScan(@NonNull List<ScanFilter> scanFilters, @NonNull Context context) { if (pendingIntentIsScanning.get()) { Timber.w("Not starting scan, you can't start a background scan without cancelling your existing scan"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); return false; } if (scanCount.get() >= MAX_SCANS_ALLOWED_PER_30_SECONDS) { Timber.e("Yo Dawg I heard u like scanning ... You have already started %d scanners in this 30s, you must wait", MAX_SCANS_ALLOWED_PER_30_SECONDS); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); return false; } if (atLeastSDK(Build.VERSION_CODES.O)) { BluetoothAdapter adapter = bleUtils.getBluetoothAdapter(context); if (adapter == null) { return false; } if (scanFilters.isEmpty()) { Timber.w("You can not start a background scan with no filters."); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); return false; } backgroundIntentBasedScanIntent = getSystemPendingIntent(context); // if a pending intent scan is underway we will clear it first as we now know that // this consumes gatt_ifs we can not have more than one ever running at a time // we can't rely on the boolean because we might have been killed between calls // and need to cancel anyway so we will try to proactively cancel stopBackgroundScan(backgroundIntentBasedScanIntent); int didStart; try { didStart = scanner.startScan(scanFilters, null, backgroundIntentBasedScanIntent); } catch (NullPointerException e) { //https://fabric.io/fitbit7/android/apps/com.fitbit.fitbitmobile/issues/2e9748f5333eaa38d7f7141f73139504?time=last-ninety-days Timber.w(e, "Could not start scan, android internal stack NPE"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); return false; } int count = scanCount.incrementAndGet(); Timber.v("Starting scan, scan count in this %d ms is %d", getScannerDuration(), count); if (didStart == 0) { Timber.d("You have started a system background scan, any other scan is still running"); boolean oldValue = pendingIntentIsScanning.getAndSet(true); Timber.v("Scan started, changing from scanning status %b to %b", oldValue, pendingIntentIsScanning.get()); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); } else { switch (didStart) { case SCAN_FAILED_ALREADY_STARTED: Timber.d("Can't start scan, already started"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); break; case SCAN_FAILED_APPLICATION_REGISTRATION_FAILED: Timber.d("Can't start scan, application registration failed"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); break; case SCAN_FAILED_INTERNAL_ERROR: Timber.d("Can't start scan, internal error"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); break; case SCAN_FAILED_FEATURE_UNSUPPORTED: Timber.d("Can't start scan, feature unsupported"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); break; default: Timber.d("Can't start scan, out of hardware resources, or scanning too frequently."); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); break; } return false; } listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); stopPeriodicalScan = false; return true; } else { return false; } } private PendingIntent getSystemPendingIntent(Context context){ Intent broadcastIntent = new Intent(context, HandleIntentBasedScanResult.class); broadcastIntent.setAction(SCANNED_DEVICE_ACTION); broadcastIntent.setClass(context, HandleIntentBasedScanResult.class); return PendingIntent.getBroadcast(context, BACKGROUND_SCAN_REQUEST_CODE, broadcastIntent, PendingIntent.FLAG_UPDATE_CURRENT); } /** * Will start a background scan that will continue to run even if our process is killed. * * this call will consume a gatt_if * so Fitbitgatt will not let you have more than one scan running at a time, if you start a new scan * it will stop the previous one. * * @param macAddresses The specific mac addresses for which to be called back * @param broadcastIntent The broadcast intent to be sent when the device is found. * Will wake up application if process is dead. * @param context The Android context for creating the pending intent * @return The pending intent that should be used to cancel the scan if desired, or null * if the scan wasn't started. */ @Nullable PendingIntent startBackgroundScan(List<String> macAddresses, Intent broadcastIntent, Context context) { if (atLeastSDK(Build.VERSION_CODES.O)) { BluetoothAdapter adapter = bleUtils.getBluetoothAdapter(context); if (adapter == null) { listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); return null; } if (!scanner.isBluetoothEnabled()) { Timber.w("Scanner cannot be started when Bluetooth is off"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); return null; } if (scanCount.get() >= MAX_SCANS_ALLOWED_PER_30_SECONDS) { Timber.e("Yo Dawg I heard u like scanning ... You have already started %d scanners in this 30s, you must wait", MAX_SCANS_ALLOWED_PER_30_SECONDS); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); return null; } List<ScanFilter> filters = new ArrayList<>(macAddresses.size()); // if filters are already set up, we should use them synchronized (scanFilters) { filters.addAll(scanFilters); } // check to ensure that the address provided is valid if (filters.isEmpty()) { Timber.w("You can not start a background scan with no filters."); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); return null; } PendingIntent pending = PendingIntent.getBroadcast(context, BACKGROUND_SCAN_REQUEST_CODE, broadcastIntent, PendingIntent.FLAG_UPDATE_CURRENT); // if a pending intent scan is underway we will clear it first as we now know that // this consumes gatt_ifs we can not have more than one ever running at a time // we can't rely on the boolean because we might have been killed between calls // and need to cancel anyway so we will try to proactively cancel stopBackgroundScan(pending); // in addition if there are mac addresses that we want to add we can do that for (String address : macAddresses) { if (adapter.getRemoteDevice(address) != null) { Timber.v("Starting background scan for device : %s", address); // you are not allowed to scan all, it will crash the app ScanFilter filter = new ScanFilter.Builder().setDeviceAddress(address).build(); filters.add(filter); } else { Timber.w("Invalid address %s provided.", address); } } if (!filters.isEmpty()) { int didStart; try { didStart = scanner.startScan(filters, null, pending); } catch (NullPointerException e) { Timber.w(e, "Could not start scan, android internal stack NPE"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); return null; } int count = scanCount.incrementAndGet(); Timber.v("Starting scan, scan count in this %d ms is %d", getScannerDuration(), count); if (didStart == 0) { Timber.d("You have started a DIY system background scan, stopping periodical scan until background scan is stopped"); boolean oldValue = pendingIntentIsScanning.getAndSet(true); Timber.v("Scan started, changing from scanning status %b to %b", oldValue, pendingIntentIsScanning.get()); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); } else { switch (didStart) { case SCAN_FAILED_ALREADY_STARTED: Timber.d("Can't start scan, already started"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); break; case SCAN_FAILED_APPLICATION_REGISTRATION_FAILED: Timber.d("Can't start scan, application registration failed"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); break; case SCAN_FAILED_INTERNAL_ERROR: Timber.d("Can't start scan, internal error"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); break; case SCAN_FAILED_FEATURE_UNSUPPORTED: Timber.d("Can't start scan, feature unsupported"); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); break; default: Timber.d("Can't start scan, out of hardware resources, or scanning too frequently."); listener.onPendingIntentScanStatusChanged(pendingIntentIsScanning.get()); break; } return null; } stopPeriodicalScan = false; return pending; } else { return null; } } else { return null; } } /** * Will stop a previously started background scan * * @param pendingIntent The pending intent used to start the background scan */ void stopBackgroundScan(PendingIntent pendingIntent) { if (atLeastSDK(Build.VERSION_CODES.O)) { if (!scanner.isBluetoothEnabled()) { Timber.v("No scanners can be started while bluetooth is off"); // must release the scanner here so that the system can clean it up since // we can't access it with bt off synchronized (PeripheralScanner.class) { scanner = new BitgattLeScanner(FitbitGatt.getInstance().getAppContext()); } return; } scanner.stopScan(pendingIntent); stopPeriodicalScan = false; } else { Timber.v("This type of scan can not be executed or stopped if not API 26+"); } } private synchronized boolean startScan(@Nullable Context context) { ArrayList<ScanFilter> filters; synchronized (scanFilters) { filters = new ArrayList<>(scanFilters); } if (context == null) { Timber.v("Can't start scan with a null context"); return false; } if (scanCount.get() >= MAX_SCANS_ALLOWED_PER_30_SECONDS) { Timber.e("Yo Dawg I heard u like scanning ... You have already started %d scanners in this %d, you must wait", MAX_SCANS_ALLOWED_PER_30_SECONDS, getScannerDuration()); listener.onScanStatusChanged(isScanning.get()); return false; } //remove timeout and other scan request mHandler.removeCallbacks(scanTimeoutRunnable); mHandler.removeCallbacks(periodicRunnable); //start scan if (!isScanning.getAndSet(true)) { // we should just use a basic one if we are in mock mode ScanSettings settings; if (bleUtils.getBluetoothAdapter(context) == null) { settings = new ScanSettings.Builder().build(); } else { settings = new ScanSettings.Builder().setScanMode(scanMode).build(); // don't start a scanner without scan filters Timber.v("Scan filter's size: %s", filters.size()); if (filters.isEmpty()) { Timber.w("We will not start a scan without filters"); isScanning.set(false); listener.onScanStatusChanged(isScanning.get()); return false; } } boolean scanStarted; // if the scanner is null here it's either because BT is off / bt is in the // simulator or because we removed it before because the user disabled bt // we can get here is scanner had previously been defined, but now the // adapter is turned off if (scanner.isBluetoothEnabled()) { try { scanner.startScan(filters, settings, callback); } catch (NullPointerException e) { Timber.w(e, "Couldn't start the scanner, android internal NPE"); isScanning.set(false); listener.onScanStatusChanged(isScanning.get()); return false; } int count = scanCount.incrementAndGet(); Timber.v("Starting scan, scan count in this %d ms is %d", getScannerDuration(), count); scanStarted = true; } else { Timber.w("BT Seems to be off, not starting scan"); isScanning.set(false); scanStarted = false; } listener.onScanStatusChanged(isScanning.get()); resetScanBackoff = false; //Schedule timeout mHandler.postDelayed(scanTimeoutRunnable, getScannerDuration()); return scanStarted; } else { Timber.w("Already scanning, will not start a new scan"); return false; } } /** * Will stop an ongoing scan * * @param context The android context */ @SuppressWarnings("WeakerAccess") // API Method synchronized void stopScan(@Nullable Context context) { if (context == null) { Timber.v("Can't stop scan with a null context"); return; } if (scanner.isBluetoothEnabled()) { scanner.flushPendingScanResults(callback); boolean oldValue = isScanning.getAndSet(false); Timber.v("Stopping scan, changing from %b to %b", oldValue, false); scanner.stopScan(callback); } else { // adapter was null or BT was off Timber.w("Bluetooth must have been turned off"); boolean oldValue = isScanning.getAndSet(false); Timber.v("Stopping scan, changing from %b to %b", oldValue, false); listener.onScanStatusChanged(false); } listener.onScanStatusChanged(false); } @VisibleForTesting(otherwise = VisibleForTesting.NONE) void populateMockScanResultBatchValues(List<ScanResult> scanResults) { if (callback != null) { callback.onBatchScanResults(scanResults); } } @VisibleForTesting(otherwise = VisibleForTesting.NONE) @SuppressWarnings("SameParameterValue") // API Method void populateMockScanResultIndividualValue(int callbackType, ScanResult scanResult) { if (callback != null) { callback.onScanResult(callbackType, scanResult); } } void onDeviceDisconnected(BluetoothDevice device) { if (foundDevices != null && device != null) { foundDevices.remove(device.getAddress()); } resetScanBackoff = true; if (!isScanning.get() && periodicalScanEnabled.get()) { Timber.v("Not scanning when device disconnected, let's try to get it back."); scanBackoffMultiplier = DEFAULT_SCAN_BACKOFF_MULTIPLIER; startPeriodicScan(FitbitGatt.getInstance().getAppContext()); } } void recycleLeScanner() { synchronized (PeripheralScanner.class) { scanner = new BitgattLeScanner(FitbitGatt.getInstance().getAppContext()); } } class PeriodicScanRunnable implements Runnable { @Override public void run() { startPeriodicScan(FitbitGatt.getInstance().getAppContext()); } } @SuppressWarnings("PMD.AccessorMethodGeneration") class ScanTimeoutRunnable implements Runnable { @Override public void run() { Timber.d("Scan timeout"); stopScan(FitbitGatt.getInstance().getAppContext()); if (periodicalScanEnabled.get()) { if (resetScanBackoff) { scanBackoffMultiplier = DEFAULT_SCAN_BACKOFF_MULTIPLIER; } else { scanBackoffMultiplier = Math.min(MAX_BACKOFF_MULTIPLIER, scanBackoffMultiplier << 1); } Timber.d("Posting the periodic start to run in %d ms", scanBackoffMultiplier * getScannerInterval()); mHandler.postDelayed(periodicRunnable, scanBackoffMultiplier * getScannerInterval()); } } } }