package org.altbeacon.bluetooth; import android.app.NotificationChannel; 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.Build; import android.os.Handler; import android.os.Looper; import android.os.PersistableBundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import androidx.localbroadcastmanager.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: * * <code> * BluetoothMedic medic = BluetoothMedic.getInstance(); * medic.enablePowerCycleOnFailures(context); * medic.enablePeriodicTests(context, BluetoothMedic.SCAN_TEST | BluetoothMedic.TRANSMIT_TEST); * </code> * * To set up in a manual way: * * <code> * 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(Looper.getMainLooper()); private int mTestType = 0; @Nullable private Boolean mTransmitterTestResult = null; @Nullable private Boolean mScanTestResult = null; private boolean mNotificationsEnabled = false; private boolean mNotificationChannelCreated = 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<ScanResult> 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) { try { 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; } } scanner.stopScan(callback); } catch (IllegalStateException e) { LogManager.d(TAG, "Bluetooth is off. Cannot run scan test."); } catch (NullPointerException e) { // Needed to stop a crash caused by internal NPE thrown by Android. See issue #636 LogManager.e(TAG, "NullPointerException. Cannot run scan test.", e); } } 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 (mAdapter != null) { final BluetoothLeAdvertiser advertiser = getAdvertiserSafely(mAdapter); 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) { if (!this.mNotificationChannelCreated) { createNotificationChannel(context, "err"); } 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 createNotificationChannel(Context context, String channelId) { // On Android 8.0 and above posting a notification without a // channel is an error. So create a notification channel 'err' if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { String channelName = "Errors"; String description = "Scan errors"; int importance = NotificationManager.IMPORTANCE_DEFAULT; NotificationChannel channel = new NotificationChannel(channelId, channelName, importance); channel.setDescription(description); NotificationManager notificationManager = context.getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); mNotificationChannelCreated = true; } } @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()); } } @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) private BluetoothLeAdvertiser getAdvertiserSafely(BluetoothAdapter adapter) { try { // This can sometimes throw a NullPointerException as reported here: // https://github.com/AltBeacon/android-beacon-library/issues/672 return adapter.getBluetoothLeAdvertiser(); } catch (Exception e) { LogManager.w(TAG, "Cannot get bluetoothLeAdvertiser", e); } return null; } }