package org.altbeacon.beacon.service.scanner; import android.Manifest; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.app.AlarmManager; import android.app.PendingIntent; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothManager; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.os.SystemClock; import org.altbeacon.beacon.BeaconManager; import org.altbeacon.beacon.logging.LogManager; import org.altbeacon.beacon.startup.StartupBroadcastReceiver; import org.altbeacon.bluetooth.BluetoothCrashResolver; import java.util.Date; @TargetApi(18) public abstract class CycledLeScanner { private static final String TAG = "CycledLeScanner"; private BluetoothAdapter mBluetoothAdapter; private long mLastScanCycleStartTime = 0l; private long mLastScanCycleEndTime = 0l; protected long mNextScanCycleStartTime = 0l; private long mScanCycleStopTime = 0l; private boolean mScanning; protected boolean mScanningPaused; private boolean mScanCyclerStarted = false; private boolean mScanningEnabled = false; protected final Context mContext; private long mScanPeriod; protected long mBetweenScanPeriod; protected final Handler mHandler = new Handler(Looper.getMainLooper()); protected final Handler mScanHandler; private final HandlerThread mScanThread; protected final BluetoothCrashResolver mBluetoothCrashResolver; protected final CycledLeScanCallback mCycledLeScanCallback; protected boolean mBackgroundFlag = false; protected boolean mRestartNeeded = false; private boolean mDistinctPacketsDetectedPerScan = false; private static final long ANDROID_N_MIN_SCAN_CYCLE_MILLIS = 6000l; protected CycledLeScanner(Context context, long scanPeriod, long betweenScanPeriod, boolean backgroundFlag, CycledLeScanCallback cycledLeScanCallback, BluetoothCrashResolver crashResolver) { mScanPeriod = scanPeriod; mBetweenScanPeriod = betweenScanPeriod; mContext = context; mCycledLeScanCallback = cycledLeScanCallback; mBluetoothCrashResolver = crashResolver; mBackgroundFlag = backgroundFlag; mScanThread = new HandlerThread("CycledLeScannerThread"); mScanThread.start(); mScanHandler = new Handler(mScanThread.getLooper()); } public static CycledLeScanner createScanner(Context context, long scanPeriod, long betweenScanPeriod, boolean backgroundFlag, CycledLeScanCallback cycledLeScanCallback, BluetoothCrashResolver crashResolver) { boolean useAndroidLScanner; if (android.os.Build.VERSION.SDK_INT < 18) { LogManager.w(TAG, "Not supported prior to API 18."); return null; } if (android.os.Build.VERSION.SDK_INT < 21) { LogManager.i(TAG, "This is not Android 5.0. We are using old scanning APIs"); useAndroidLScanner = false; } else { if (BeaconManager.isAndroidLScanningDisabled()) { LogManager.i(TAG, "This Android 5.0, but L scanning is disabled. We are using old scanning APIs"); useAndroidLScanner = false; } else { LogManager.i(TAG, "This Android 5.0. We are using new scanning APIs"); useAndroidLScanner = true; } } if (useAndroidLScanner) { return new CycledLeScannerForLollipop(context, scanPeriod, betweenScanPeriod, backgroundFlag, cycledLeScanCallback, crashResolver); } else { return new CycledLeScannerForJellyBeanMr2(context, scanPeriod, betweenScanPeriod, backgroundFlag, cycledLeScanCallback, crashResolver); } } /** * Tells the cycler the scan rate and whether it is in operating in background mode. * Background mode flag is used only with the Android 5.0 scanning implementations to switch * between LOW_POWER_MODE vs. LOW_LATENCY_MODE * @param backgroundFlag */ public void setScanPeriods(long scanPeriod, long betweenScanPeriod, boolean backgroundFlag) { LogManager.d(TAG, "Set scan periods called with %s, %s Background mode must have changed.", scanPeriod, betweenScanPeriod); if (mBackgroundFlag != backgroundFlag) { mRestartNeeded = true; } mBackgroundFlag = backgroundFlag; mScanPeriod = scanPeriod; mBetweenScanPeriod = betweenScanPeriod; if (mBackgroundFlag) { LogManager.d(TAG, "We are in the background. Setting wakeup alarm"); setWakeUpAlarm(); } else { LogManager.d(TAG, "We are not in the background. Cancelling wakeup alarm"); cancelWakeUpAlarm(); } long now = SystemClock.elapsedRealtime(); if (mNextScanCycleStartTime > now) { // We are waiting to start scanning. We may need to adjust the next start time // only do an adjustment if we need to make it happen sooner. Otherwise, it will // take effect on the next cycle. long proposedNextScanStartTime = (mLastScanCycleEndTime + betweenScanPeriod); if (proposedNextScanStartTime < mNextScanCycleStartTime) { mNextScanCycleStartTime = proposedNextScanStartTime; LogManager.i(TAG, "Adjusted nextScanStartTime to be %s", new Date(mNextScanCycleStartTime - SystemClock.elapsedRealtime() + System.currentTimeMillis())); } } if (mScanCycleStopTime > now) { // we are waiting to stop scanning. We may need to adjust the stop time // only do an adjustment if we need to make it happen sooner. Otherwise, it will // take effect on the next cycle. long proposedScanStopTime = (mLastScanCycleStartTime + scanPeriod); if (proposedScanStopTime < mScanCycleStopTime) { mScanCycleStopTime = proposedScanStopTime; LogManager.i(TAG, "Adjusted scanStopTime to be %s", mScanCycleStopTime); } } } public void start() { LogManager.d(TAG, "start called"); mScanningEnabled = true; if (!mScanCyclerStarted) { scanLeDevice(true); } else { LogManager.d(TAG, "scanning already started"); } } @SuppressLint("NewApi") public void stop() { LogManager.d(TAG, "stop called"); mScanningEnabled = false; if (mScanCyclerStarted) { scanLeDevice(false); } else { LogManager.d(TAG, "scanning already stopped"); } } public boolean getDistinctPacketsDetectedPerScan() { return mDistinctPacketsDetectedPerScan; } public void setDistinctPacketsDetectedPerScan(boolean detected) { mDistinctPacketsDetectedPerScan = detected; } public void destroy() { LogManager.d(TAG, "Destroying"); // We cannot quit the thread used by the handler until queued Runnables have been processed, // because the handler is what stops scanning, and we do not want scanning left on. // So we stop the thread using the handler, so we make sure it happens after all other // waiting Runnables are finished. mHandler.post(new Runnable() { @Override public void run() { LogManager.d(TAG, "Quitting scan thread"); // Remove any postDelayed Runnables queued for the next scan cycle mHandler.removeCallbacksAndMessages(null); mScanThread.quit(); } }); } protected abstract void stopScan(); protected abstract boolean deferScanIfNeeded(); protected abstract void startScan(); @SuppressLint("NewApi") protected void scanLeDevice(final Boolean enable) { try { mScanCyclerStarted = true; if (getBluetoothAdapter() == null) { LogManager.e(TAG, "No Bluetooth adapter. beaconService cannot scan."); } if (enable) { if (deferScanIfNeeded()) { return; } LogManager.d(TAG, "starting a new scan cycle"); if (!mScanning || mScanningPaused || mRestartNeeded) { mScanning = true; mScanningPaused = false; try { if (getBluetoothAdapter() != null) { if (getBluetoothAdapter().isEnabled()) { if (mBluetoothCrashResolver != null && mBluetoothCrashResolver.isRecoveryInProgress()) { LogManager.w(TAG, "Skipping scan because crash recovery is in progress."); } else { if (mScanningEnabled) { if (mRestartNeeded) { mRestartNeeded = false; LogManager.d(TAG, "restarting a bluetooth le scan"); } else { LogManager.d(TAG, "starting a new bluetooth le scan"); } try { if (android.os.Build.VERSION.SDK_INT < 23 || checkLocationPermission()) { startScan(); } } catch (Exception e) { LogManager.e(e, TAG, "Internal Android exception scanning for beacons"); } } else { LogManager.d(TAG, "Scanning unnecessary - no monitoring or ranging active."); } } mLastScanCycleStartTime = SystemClock.elapsedRealtime(); } else { LogManager.d(TAG, "Bluetooth is disabled. Cannot scan for beacons."); } } } catch (Exception e) { LogManager.e(e, TAG, "Exception starting Bluetooth scan. Perhaps Bluetooth is disabled or unavailable?"); } } else { LogManager.d(TAG, "We are already scanning"); } mScanCycleStopTime = (SystemClock.elapsedRealtime() + mScanPeriod); scheduleScanCycleStop(); LogManager.d(TAG, "Scan started"); } else { LogManager.d(TAG, "disabling scan"); mScanning = false; mScanCyclerStarted = false; stopScan(); mLastScanCycleEndTime = SystemClock.elapsedRealtime(); } } catch (SecurityException e) { LogManager.w(TAG, "SecurityException working accessing bluetooth."); } } protected void scheduleScanCycleStop() { // Stops scanning after a pre-defined scan period. long millisecondsUntilStop = mScanCycleStopTime - SystemClock.elapsedRealtime(); if (millisecondsUntilStop > 0) { LogManager.d(TAG, "Waiting to stop scan cycle for another %s milliseconds", millisecondsUntilStop); if (mBackgroundFlag) { setWakeUpAlarm(); } mHandler.postDelayed(new Runnable() { @Override public void run() { scheduleScanCycleStop(); } }, millisecondsUntilStop > 1000 ? 1000 : millisecondsUntilStop); } else { finishScanCycle(); } } protected abstract void finishScan(); private void finishScanCycle() { LogManager.d(TAG, "Done with scan cycle"); try { mCycledLeScanCallback.onCycleEnd(); if (mScanning) { if (getBluetoothAdapter() != null) { if (getBluetoothAdapter().isEnabled()) { // Determine if we need to restart scanning. Restarting scanning is only // needed on devices incapable of detecting multiple distinct BLE advertising // packets in a single cycle, typically older Android devices (e.g. Nexus 4) // On such devices, it is necessary to stop scanning and restart to detect // multiple beacon packets in the same scan, allowing collection of multiple // rssi measurements. Restarting however, causes brief detection dropouts // so it is best avoided. If we know the device has detected to distinct // packets in the same cycle, we will not restart scanning and just keep it // going. if (!getDistinctPacketsDetectedPerScan() || mBetweenScanPeriod != 0) { long now = SystemClock.elapsedRealtime(); if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && mBetweenScanPeriod+mScanPeriod < ANDROID_N_MIN_SCAN_CYCLE_MILLIS && now-mLastScanCycleStartTime < ANDROID_N_MIN_SCAN_CYCLE_MILLIS) { // As of Android N, only 5 scans may be started in a 30 second period (6 // seconds per cycle) otherwise they are blocked. So we check here to see // if the scan period is 6 seconds or less, and if we last stopped scanning // fewer than 6 seconds ag and if so, we simply do not stop scanning LogManager.d(TAG, "Not stopping scan because this is Android N and we" + " keep scanning for a minimum of 6 seconds at a time. "+ "We will stop in "+(ANDROID_N_MIN_SCAN_CYCLE_MILLIS-(now-mLastScanCycleStartTime))+" millisconds."); } else { try { LogManager.d(TAG, "stopping bluetooth le scan"); finishScan(); } catch (Exception e) { LogManager.w(e, TAG, "Internal Android exception scanning for beacons"); } } } else { LogManager.d(TAG, "Not stopping scanning. Device capable of multiple indistinct detections per scan."); } mLastScanCycleEndTime = SystemClock.elapsedRealtime(); } else { LogManager.d(TAG, "Bluetooth is disabled. Cannot scan for beacons."); } } mNextScanCycleStartTime = getNextScanStartTime(); if (mScanningEnabled) { scanLeDevice(true); } } if (!mScanningEnabled) { LogManager.d(TAG, "Scanning disabled. No ranging or monitoring regions are active."); mScanCyclerStarted = false; cancelWakeUpAlarm(); } } catch (SecurityException e) { LogManager.w(TAG, "SecurityException working accessing bluetooth."); } } protected BluetoothAdapter getBluetoothAdapter() { try { if (mBluetoothAdapter == null) { // Initializes Bluetooth adapter. final BluetoothManager bluetoothManager = (BluetoothManager) mContext.getApplicationContext().getSystemService(Context.BLUETOOTH_SERVICE); mBluetoothAdapter = bluetoothManager.getAdapter(); if (mBluetoothAdapter == null) { LogManager.w(TAG, "Failed to construct a BluetoothAdapter"); } } } catch (SecurityException e) { // Thrown by Samsung Knox devices if bluetooth access denied for an app LogManager.e(TAG, "Cannot consruct bluetooth adapter. Security Exception"); } return mBluetoothAdapter; } private PendingIntent mWakeUpOperation = null; // In case we go into deep sleep, we will set up a wakeup alarm when in the background to kickoff // off the scan cycle again protected void setWakeUpAlarm() { // wake up time will be the maximum of 5 minutes, the scan period, the between scan period long milliseconds = 1000l * 60 * 5; /* five minutes */ if (milliseconds < mBetweenScanPeriod) { milliseconds = mBetweenScanPeriod; } if (milliseconds < mScanPeriod) { milliseconds = mScanPeriod; } AlarmManager alarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + milliseconds, getWakeUpOperation()); LogManager.d(TAG, "Set a wakeup alarm to go off in %s ms: %s", milliseconds, getWakeUpOperation()); } protected PendingIntent getWakeUpOperation() { if (mWakeUpOperation == null) { Intent wakeupIntent = new Intent(mContext, StartupBroadcastReceiver.class); wakeupIntent.putExtra("wakeup", true); mWakeUpOperation = PendingIntent.getBroadcast(mContext, 0, wakeupIntent, PendingIntent.FLAG_UPDATE_CURRENT); } return mWakeUpOperation; } protected void cancelWakeUpAlarm() { LogManager.d(TAG, "cancel wakeup alarm: %s", mWakeUpOperation); // We actually don't cancel the wakup alarm... we just reschedule for a long time in the // future. This is to get around a limit on 500 alarms you can start per app on Samsung // devices. long milliseconds = Long.MAX_VALUE; // 2.9 million years from now AlarmManager alarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, milliseconds, getWakeUpOperation()); LogManager.d(TAG, "Set a wakeup alarm to go off in %s ms: %s", milliseconds - SystemClock.elapsedRealtime(), getWakeUpOperation()); } private long getNextScanStartTime() { // Because many apps may use this library on the same device, we want to try to synchronize // scanning as much as possible in order to save battery. Therefore, we will set the scan // intervals to be on a predictable interval using a modulus of the system time. This may // cause scans to start a little earlier than otherwise, but it should be acceptable. // This way, if multiple apps on the device are using the default scan periods, then they // will all be doing scans at the same time, thereby saving battery when none are scanning. // This, of course, won't help at all if people set custom scan periods. But since most // people accept the defaults, this will likely have a positive effect. if (mBetweenScanPeriod == 0) { return SystemClock.elapsedRealtime(); } long fullScanCycle = mScanPeriod + mBetweenScanPeriod; long normalizedBetweenScanPeriod = mBetweenScanPeriod-(SystemClock.elapsedRealtime() % fullScanCycle); LogManager.d(TAG, "Normalizing between scan period from %s to %s", mBetweenScanPeriod, normalizedBetweenScanPeriod); return SystemClock.elapsedRealtime()+normalizedBetweenScanPeriod; } private boolean checkLocationPermission() { return checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION) || checkPermission(Manifest.permission.ACCESS_FINE_LOCATION); } private boolean checkPermission(final String permission) { return mContext.checkPermission(permission, android.os.Process.myPid(), android.os.Process.myUid()) == PackageManager.PERMISSION_GRANTED; } }