diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb9cc795..f9cef014a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ### Development Enhancements: + - Add BluetoothMedic to fix crashing bluetooth stacks. (#644, David G. Young) - Allow configuring job ids (#645, David G. Young) Bug Fixes: diff --git a/build.gradle b/build.gradle index 8d38dd859..8b3dc319c 100644 --- a/build.gradle +++ b/build.gradle @@ -34,7 +34,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.0.0-rc2' + classpath 'com.android.tools.build:gradle:3.0.1' classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.7.3' classpath 'org.jfrog.buildinfo:build-info-extractor-gradle:3.0.3' } diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index c7362b5f0..75e68b55d 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -29,10 +29,15 @@ /> + android:permission="android.permission.BIND_JOB_SERVICE"> + + + + diff --git a/src/main/java/org/altbeacon/beacon/service/scanner/CycledLeScannerForLollipop.java b/src/main/java/org/altbeacon/beacon/service/scanner/CycledLeScannerForLollipop.java index 6ed8215ca..011c890d1 100644 --- a/src/main/java/org/altbeacon/beacon/service/scanner/CycledLeScannerForLollipop.java +++ b/src/main/java/org/altbeacon/beacon/service/scanner/CycledLeScannerForLollipop.java @@ -8,10 +8,12 @@ import android.bluetooth.le.ScanResult; import android.bluetooth.le.ScanSettings; import android.content.Context; +import android.content.Intent; import android.os.ParcelUuid; import android.os.SystemClock; import android.support.annotation.MainThread; import android.support.annotation.WorkerThread; +import android.support.v4.content.LocalBroadcastManager; import org.altbeacon.beacon.BeaconManager; import org.altbeacon.beacon.logging.LogManager; @@ -29,7 +31,7 @@ public class CycledLeScannerForLollipop extends CycledLeScanner { private BluetoothLeScanner mScanner; private ScanCallback leScanCallback; private long mBackgroundLScanStartTime = 0l; - private long mBackgroundLScanFirstDetectionTime = 0l; + private long mBackgroundLScanFirstDetectionTime = 0; private boolean mMainScanCycleActive = false; private final BeaconManager mBeaconManager; @@ -326,6 +328,9 @@ public void onBatchScanResults(List results) { @MainThread @Override public void onScanFailed(int errorCode) { + Intent intent = new Intent("onScanFailed"); + intent.putExtra("errorCode", errorCode); + LocalBroadcastManager.getInstance(CycledLeScannerForLollipop.this.mContext).sendBroadcast(intent); switch (errorCode) { case SCAN_FAILED_ALREADY_STARTED: LogManager.e( diff --git a/src/main/java/org/altbeacon/bluetooth/BluetoothMedic.java b/src/main/java/org/altbeacon/bluetooth/BluetoothMedic.java new file mode 100644 index 000000000..96425e523 --- /dev/null +++ b/src/main/java/org/altbeacon/bluetooth/BluetoothMedic.java @@ -0,0 +1,457 @@ +package org.altbeacon.bluetooth; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.TaskStackBuilder; +import android.app.job.JobScheduler; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.AdvertiseCallback; +import android.bluetooth.le.AdvertiseData; +import android.bluetooth.le.AdvertiseSettings; +import android.bluetooth.le.BluetoothLeAdvertiser; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.AdvertiseSettings.Builder; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Handler; +import android.os.PersistableBundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.v4.app.NotificationCompat; +import android.support.v4.content.LocalBroadcastManager; +import java.util.List; + +import org.altbeacon.beacon.logging.LogManager; + +/** + * + * Utility class for checking the health of the bluetooth stack on the device by running two kinds + * of tests: scanning and transmitting. The class looks for specific failure codes from these + * tests to determine if the bluetooth stack is in a bad state and if so, optionally cycle power to + * bluetooth to try and fix the problem. This is known to work well on some Android devices. + * + * The tests may be called directly, or set up to run automatically approximately every 15 minutes. + * To set up in an automated way: + * + * + * BluetoothMedic medic = BluetoothMedic.getInstance(); + * medic.enablePowerCycleOnFailures(context); + * medic.enablePeriodicTests(context, BluetoothMedic.SCAN_TEST | BluetoothMedic.TRANSMIT_TEST); + * + * + * To set up in a manual way: + * + * + * BluetoothMedic medic = BluetoothMedic.getInstance(); + * medic.enablePowerCycleOnFailures(context); + * if (!medic.runScanTest(context)) { + * // Bluetooth stack is in a bad state + * } + * if (!medic.runTransmitterTest(context)) { + * // Bluetooth stack is in a bad state + * } + * + */ + +@SuppressWarnings("javadoc") +public class BluetoothMedic { + + /** + * Indicates that no test should be run by the BluetoothTestJob + */ + @SuppressWarnings("WeakerAccess") + public static final int NO_TEST = 0; + /** + * Indicates that the transmitter test should be run by the BluetoothTestJob + */ + @SuppressWarnings("WeakerAccess") + public static final int TRANSMIT_TEST = 2; + /** + * Indicates that the bluetooth scan test should be run by the BluetoothTestJob + */ + @SuppressWarnings("WeakerAccess") + public static final int SCAN_TEST = 1; + private static final String TAG = BluetoothMedic.class.getSimpleName(); + @Nullable + private BluetoothAdapter mAdapter; + @Nullable + private LocalBroadcastManager mLocalBroadcastManager; + @NonNull + private Handler mHandler = new Handler(); + private int mTestType = 0; + @Nullable + private Boolean mTransmitterTestResult = null; + @Nullable + private Boolean mScanTestResult = null; + private boolean mNotificationsEnabled = false; + private int mNotificationIcon = 0; + private long mLastBluetoothPowerCycleTime = 0L; + private static final long MIN_MILLIS_BETWEEN_BLUETOOTH_POWER_CYCLES = 60000L; + @Nullable + private static BluetoothMedic sInstance; + @RequiresApi(21) + private BroadcastReceiver mBluetoothEventReceiver = new BroadcastReceiver() { + public void onReceive(Context context, Intent intent) { + LogManager.d(BluetoothMedic.TAG, "Broadcast notification received."); + int errorCode; + String action = intent.getAction(); + if (action != null) { + if(action.equalsIgnoreCase("onScanFailed")) { + errorCode = intent.getIntExtra("errorCode", -1); + if(errorCode == 2) { + BluetoothMedic.this.sendNotification(context, "scan failed", + "Power cycling bluetooth"); + LogManager.d(BluetoothMedic.TAG, + "Detected a SCAN_FAILED_APPLICATION_REGISTRATION_FAILED. We need to cycle bluetooth to recover"); + if(!BluetoothMedic.this.cycleBluetoothIfNotTooSoon()) { + BluetoothMedic.this.sendNotification(context, "scan failed", "" + + "Cannot power cycle bluetooth again"); + } + } + } else if(action.equalsIgnoreCase("onStartFailed")) { + errorCode = intent.getIntExtra("errorCode", -1); + if(errorCode == 4) { + BluetoothMedic.this.sendNotification(context, "advertising failed", + "Expected failure. Power cycling."); + if(!BluetoothMedic.this.cycleBluetoothIfNotTooSoon()) { + BluetoothMedic.this.sendNotification(context, "advertising failed", + "Cannot power cycle bluetooth again"); + } + } + } else { + LogManager.d(BluetoothMedic.TAG, "Unknown event."); + } + } + } + }; + + + /** + * Get a singleton instance of the BluetoothMedic + * @return + */ + public static BluetoothMedic getInstance() { + if(sInstance == null) { + sInstance = new BluetoothMedic(); + } + return sInstance; + } + + private BluetoothMedic() { + } + + @RequiresApi(21) + private void initializeWithContext(Context context) { + if (this.mAdapter == null || this.mLocalBroadcastManager == null) { + BluetoothManager manager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE); + if(manager == null) { + throw new NullPointerException("Cannot get BluetoothManager"); + } else { + this.mAdapter = manager.getAdapter(); + this.mLocalBroadcastManager = LocalBroadcastManager.getInstance(context); + } + } + } + + /** + * If set to true, bluetooth will be power cycled on any tests run that determine bluetooth is + * in a bad state. + * + * @param context + */ + @SuppressWarnings("unused") + @RequiresApi(21) + public void enablePowerCycleOnFailures(Context context) { + initializeWithContext(context); + if (this.mLocalBroadcastManager != null) { + this.mLocalBroadcastManager.registerReceiver(this.mBluetoothEventReceiver, + new IntentFilter("onScanFailed")); + this.mLocalBroadcastManager.registerReceiver(this.mBluetoothEventReceiver, + new IntentFilter("onStartFailure")); + LogManager.d(TAG, + "Medic monitoring for transmission and scan failure notifications with receiver: " + + this.mBluetoothEventReceiver); + } + } + + /** + * Calling this method starts a scheduled job that will run tests of the specified type to + * make sure bluetooth is OK and cycle power to bluetooth if needed and configured by + * enablePowerCycleOnFailures + * + * @param context + * @param testType e.g. BluetoothMedic.TRANSMIT_TEST | BluetoothMedic.SCAN_TEST + */ + @SuppressWarnings("unused") + @RequiresApi(21) + public void enablePeriodicTests(Context context, int testType) { + initializeWithContext(context); + this.mTestType = testType; + LogManager.d(TAG, "Medic scheduling periodic tests of types " + testType); + this.scheduleRegularTests(context); + } + + /** + * Starts up a brief blueooth scan with the intent of seeing if it results in an error condition + * indicating the bluetooth stack may be in a bad state. + * + * If the failure error code matches a pattern known to be associated with a bad bluetooth stack + * state, then the bluetooth stack is turned off and then back on after a short delay in order + * to try to recover. + * + * @return false if the test indicates a failure indicating a bad state of the bluetooth stack + */ + @SuppressWarnings({"unused","WeakerAccess"}) + @RequiresApi(21) + public boolean runScanTest(final Context context) { + initializeWithContext(context); + this.mScanTestResult = null; + LogManager.i(TAG, "Starting scan test"); + final long testStartTime = System.currentTimeMillis(); + if (this.mAdapter != null) { + final BluetoothLeScanner scanner = this.mAdapter.getBluetoothLeScanner(); + final ScanCallback callback = new ScanCallback() { + public void onScanResult(int callbackType, ScanResult result) { + super.onScanResult(callbackType, result); + BluetoothMedic.this.mScanTestResult = true; + LogManager.i(BluetoothMedic.TAG, "Scan test succeeded"); + try { + scanner.stopScan(this); + } + catch (IllegalStateException e) { /* do nothing */ } // caught if bluetooth is off here + } + + public void onBatchScanResults(List results) { + super.onBatchScanResults(results); + } + + public void onScanFailed(int errorCode) { + super.onScanFailed(errorCode); + LogManager.d(BluetoothMedic.TAG, "Sending onScanFailed broadcast with " + + BluetoothMedic.this.mLocalBroadcastManager); + Intent intent = new Intent("onScanFailed"); + intent.putExtra("errorCode", errorCode); + if (BluetoothMedic.this.mLocalBroadcastManager != null) { + BluetoothMedic.this.mLocalBroadcastManager.sendBroadcast(intent); + } + LogManager.d(BluetoothMedic.TAG, "broadcast: " + intent + + " should be received by " + BluetoothMedic.this.mBluetoothEventReceiver); + if(errorCode == 2) { + LogManager.w(BluetoothMedic.TAG, + "Scan test failed in a way we consider a failure"); + BluetoothMedic.this.sendNotification(context, + "scan failed", "bluetooth not ok"); + BluetoothMedic.this.mScanTestResult = false; + } else { + LogManager.i(BluetoothMedic.TAG, + "Scan test failed in a way we do not consider a failure"); + BluetoothMedic.this.mScanTestResult = true; + } + + } + }; + if(scanner != null) { + scanner.startScan(callback); + while (this.mScanTestResult == null) { + LogManager.d(TAG, "Waiting for scan test to complete..."); + + try { + Thread.sleep(1000L); + } catch (InterruptedException e) { /* do nothing */ } + + if (System.currentTimeMillis() - testStartTime > 5000L) { + LogManager.d(TAG, "Timeout running scan test"); + break; + } + } + try { + scanner.stopScan(callback); + } catch (IllegalStateException e) { /* do nothing */ } // caught if bluetooth is off here + } + else { + LogManager.d(TAG, "Cannot get scanner"); + } + } + + + + LogManager.d(TAG, "scan test complete"); + return this.mScanTestResult == null || this.mScanTestResult; + } + + /** + * Starts up a beacon transmitter with the intent of seeing if it results in an error condition + * indicating the bluetooth stack may be in a bad state. + * + * If the failure error code matches a pattern known to be associated with a bad bluetooth stack + * state, then the bluetooth stack is turned off and then back on after a short delay in order + * to try to recover. + * + * @return false if the test indicates a failure indicating a bad state of the bluetooth stack + */ + @SuppressWarnings({"unused","WeakerAccess"}) + @RequiresApi(21) + public boolean runTransmitterTest(final Context context) { + initializeWithContext(context); + this.mTransmitterTestResult = null; + long testStartTime = System.currentTimeMillis(); + if (this.mAdapter != null) { + final BluetoothLeAdvertiser advertiser = this.mAdapter.getBluetoothLeAdvertiser(); + if(advertiser != null) { + AdvertiseSettings settings = (new Builder()).setAdvertiseMode(0).build(); + AdvertiseData data = (new android.bluetooth.le.AdvertiseData.Builder()) + .addManufacturerData(0, new byte[]{0}).build(); + LogManager.i(TAG, "Starting transmitter test"); + advertiser.startAdvertising(settings, data, new AdvertiseCallback() { + public void onStartSuccess(AdvertiseSettings settingsInEffect) { + super.onStartSuccess(settingsInEffect); + LogManager.i(BluetoothMedic.TAG, "Transmitter test succeeded"); + advertiser.stopAdvertising(this); + BluetoothMedic.this.mTransmitterTestResult = true; + } + + public void onStartFailure(int errorCode) { + super.onStartFailure(errorCode); + Intent intent = new Intent("onStartFailed"); + intent.putExtra("errorCode", errorCode); + LogManager.d(BluetoothMedic.TAG, "Sending onStartFailure broadcast with " + + BluetoothMedic.this.mLocalBroadcastManager); + if (BluetoothMedic.this.mLocalBroadcastManager != null) { + BluetoothMedic.this.mLocalBroadcastManager.sendBroadcast(intent); + } + if(errorCode == 4) { + BluetoothMedic.this.mTransmitterTestResult = false; + LogManager.w(BluetoothMedic.TAG, + "Transmitter test failed in a way we consider a test failure"); + BluetoothMedic.this.sendNotification(context, "transmitter failed", + "bluetooth not ok"); + } else { + BluetoothMedic.this.mTransmitterTestResult = true; + LogManager.i(BluetoothMedic.TAG, + "Transmitter test failed, but not in a way we consider a test failure"); + } + + } + }); + } else { + LogManager.d(TAG, "Cannot get advertiser"); + } + while(this.mTransmitterTestResult == null) { + LogManager.d(TAG, "Waiting for transmitter test to complete..."); + + try { + Thread.sleep(1000L); + } catch (InterruptedException e) { /* do nothing */ } + + if(System.currentTimeMillis() - testStartTime > 5000L) { + LogManager.d(TAG, "Timeout running transmitter test"); + break; + } + } + } + + LogManager.d(TAG, "transmitter test complete"); + return this.mTransmitterTestResult != null && this.mTransmitterTestResult; + } + + /** + * + * Configure whether to send user-visible notification warnings when bluetooth power is cycled. + * + * @param enabled if true, a user-visible notification is sent to tell the user when + * @param icon the icon drawable to use in notifications (e.g. R.drawable.notification_icon) + */ + @SuppressWarnings("unused") + @RequiresApi(21) + public void setNotificationsEnabled(boolean enabled, int icon) { + this.mNotificationsEnabled = enabled; + this.mNotificationIcon = icon; + } + + @RequiresApi(21) + private boolean cycleBluetoothIfNotTooSoon() { + long millisSinceLastCycle = System.currentTimeMillis() - this.mLastBluetoothPowerCycleTime; + if(millisSinceLastCycle < MIN_MILLIS_BETWEEN_BLUETOOTH_POWER_CYCLES) { + LogManager.d(TAG, "Not cycling bluetooth because we just did so " + + millisSinceLastCycle + " milliseconds ago."); + return false; + } else { + this.mLastBluetoothPowerCycleTime = System.currentTimeMillis(); + LogManager.d(TAG, "Power cycling bluetooth"); + this.cycleBluetooth(); + return true; + } + } + + @RequiresApi(21) + private void cycleBluetooth() { + LogManager.d(TAG, "Power cycling bluetooth"); + LogManager.d(TAG, "Turning Bluetooth off."); + if (mAdapter != null) { + this.mAdapter.disable(); + this.mHandler.postDelayed(new Runnable() { + public void run() { + LogManager.d(BluetoothMedic.TAG, "Turning Bluetooth back on."); + if (BluetoothMedic.this.mAdapter != null) { + BluetoothMedic.this.mAdapter.enable(); + } + } + }, 1000L); + } + else { + LogManager.w(TAG, "Cannot cycle bluetooth. Manager is null."); + } + } + + @RequiresApi(21) + private void sendNotification(Context context, String message, String detail) { + initializeWithContext(context); + if(this.mNotificationsEnabled) { + NotificationCompat.Builder builder = + (new NotificationCompat.Builder(context, "err")) + .setContentTitle("BluetoothMedic: " + message) + .setSmallIcon(mNotificationIcon) + .setVibrate(new long[]{200L, 100L, 200L}).setContentText(detail); + TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); + stackBuilder.addNextIntent(new Intent("NoOperation")); + + PendingIntent resultPendingIntent = stackBuilder.getPendingIntent( + 0, + PendingIntent.FLAG_UPDATE_CURRENT + ); + builder.setContentIntent(resultPendingIntent); + NotificationManager notificationManager = + (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager != null) { + notificationManager.notify(1, builder.build()); + } + } + } + + @RequiresApi(21) + private void scheduleRegularTests(Context context) { + initializeWithContext(context); + ComponentName serviceComponent = new ComponentName(context, BluetoothTestJob.class); + android.app.job.JobInfo.Builder builder = + new android.app.job.JobInfo.Builder(BluetoothTestJob.getJobId(context), serviceComponent); + builder.setRequiresCharging(false); + builder.setRequiresDeviceIdle(false); + builder.setPeriodic(900000L); // 900 secs is 15 minutes -- the minimum time on Android + builder.setPersisted(true); + PersistableBundle bundle = new PersistableBundle(); + bundle.putInt("test_type", this.mTestType); + builder.setExtras(bundle); + JobScheduler jobScheduler = (JobScheduler) + context.getSystemService(Context.JOB_SCHEDULER_SERVICE); + if (jobScheduler != null) { + jobScheduler.schedule(builder.build()); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/altbeacon/bluetooth/BluetoothTestJob.java b/src/main/java/org/altbeacon/bluetooth/BluetoothTestJob.java new file mode 100644 index 000000000..8d70bd982 --- /dev/null +++ b/src/main/java/org/altbeacon/bluetooth/BluetoothTestJob.java @@ -0,0 +1,138 @@ +package org.altbeacon.bluetooth; + +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.PackageItemInfo; +import android.content.pm.PackageManager; +import android.os.Handler; +import android.os.HandlerThread; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import org.altbeacon.beacon.logging.LogManager; +import org.altbeacon.beacon.service.BeaconService; + +/** + * @hide + * Internal library class. Do not use directly. + */ +@SuppressWarnings("javadoc") +@RequiresApi(21) +public class BluetoothTestJob extends JobService { + private static final String TAG = BluetoothTestJob.class.getSimpleName(); + @Nullable + private Handler mHandler = null; + @Nullable + private HandlerThread mHandlerThread = null; + private static int sOverrideJobId = -1; + + /** + * Allows configuration of the job id for the Android Job Scheduler. If not configured, this + * will default to he value in the AndroidManifest.xml + * + * WARNING: If using this library in a multi-process application, this method may not work. + * This is considered a private API and may be removed at any time. + * + * the preferred way of setting this is in the AndroidManifest.xml as so: + * + * + * + * + * + * + * @param id + */ + public static void setOverrideJobId(int id) { + sOverrideJobId = id; + } + + /** + * Returns the job id to be used to schedule this job. This may be set in the + * AndroidManifest.xml or in single process applications by using #setOverrideJobId + * @param context + * @return + */ + public static int getJobId(Context context) { + if (sOverrideJobId >= 0) { + LogManager.i(TAG, "Using BluetoothTestJob JobId from static override: "+ + sOverrideJobId); + return sOverrideJobId; + } + PackageItemInfo info = null; + try { + info = context.getPackageManager().getServiceInfo(new ComponentName(context, + BluetoothTestJob.class), PackageManager.GET_META_DATA); + } catch (PackageManager.NameNotFoundException e) { /* do nothing here */ } + if (info != null && info.metaData != null && info.metaData.get("jobId") != null) { + int jobId = info.metaData.getInt("jobId"); + LogManager.i(TAG, "Using BluetoothTestJob JobId from manifest: "+jobId); + return jobId; + } + else { + throw new RuntimeException("Cannot get job id from manifest. " + + "Make sure that the BluetoothTestJob is configured in the manifest."); + } + } + + public BluetoothTestJob() { + } + + public boolean onStartJob(final JobParameters params) { + if(this.mHandlerThread == null) { + this.mHandlerThread = new HandlerThread("BluetoothTestThread"); + this.mHandlerThread.start(); + } + + if(this.mHandler == null) { + this.mHandler = new Handler(this.mHandlerThread.getLooper()); + } + + this.mHandler.post(new Runnable() { + public void run() { + boolean found = false; + LogManager.i(BluetoothTestJob.TAG, "Bluetooth Test Job running"); + int testType = params.getExtras().getInt("test_type"); + if(testType == BluetoothMedic.NO_TEST) { + found = true; + LogManager.d(BluetoothTestJob.TAG, "No test specified. Done with job."); + } + + if((testType & BluetoothMedic.SCAN_TEST) == BluetoothMedic.SCAN_TEST) { + LogManager.d(BluetoothTestJob.TAG, "Scan test specified."); + found = true; + if (!BluetoothMedic.getInstance().runScanTest(BluetoothTestJob.this)) { + LogManager.d(TAG, "scan test failed"); + } + } + + if((testType & BluetoothMedic.TRANSMIT_TEST) == BluetoothMedic.TRANSMIT_TEST) { + if(found) { + try { + Thread.sleep(10000L); + } catch (InterruptedException e) { + /* do nothing */ + } + } + + LogManager.d(BluetoothTestJob.TAG, "Transmit test specified."); + found = true; + if (!BluetoothMedic.getInstance().runTransmitterTest(BluetoothTestJob.this)) { + LogManager.d(TAG, "transmit test failed"); + } + } + + if(!found) { + LogManager.w(BluetoothTestJob.TAG, "Unknown test type:" + testType + " Exiting."); + } + + BluetoothTestJob.this.jobFinished(params, false); + } + }); + return true; + } + + public boolean onStopJob(JobParameters params) { + return true; + } +}