package org.altbeacon.bluetooth; import android.annotation.TargetApi; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.SystemClock; import org.altbeacon.beacon.logging.LogManager; import java.io.BufferedReader; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.util.HashSet; import java.util.Set; /** * * This class provides relief for Android Bug 67272. This bug in the Bluedroid stack causes crashes * in Android's BluetoothService when scanning for BLE devices encounters a large number of unique * devices. It is rare for most users but can be problematic for those with apps scanning for * Bluetooth LE devices in the background (e.g. beacon-enabled apps), especially when these users * are around Bluetooth LE devices that randomize their mac address like Gimbal beacons. * * This class can both recover from crashes and prevent crashes from happening in the first place. * * More details on the bug can be found at the following URLs: * * https://code.google.com/p/android/issues/detail?id=67272 * https://github.com/RadiusNetworks/android-ibeacon-service/issues/16 * * Version 1.0 * * Created by dyoung on 3/24/14. */ public class BluetoothCrashResolver { private static final String TAG = "BluetoothCrashResolver"; private static final boolean PREEMPTIVE_ACTION_ENABLED = true; /** * This is not the same file that Bluedroid uses. This is just to maintain state of this module. */ private static final String DISTINCT_BLUETOOTH_ADDRESSES_FILE = "BluetoothCrashResolverState.txt"; private boolean recoveryInProgress = false; private boolean discoveryStartConfirmed = false; private long lastBluetoothOffTime = 0l; private long lastBluetoothTurningOnTime = 0l; private long lastBluetoothCrashDetectionTime = 0l; private int detectedCrashCount = 0; private int recoveryAttemptCount = 0; private boolean lastRecoverySucceeded = false; private long lastStateSaveTime = 0l; private static final long MIN_TIME_BETWEEN_STATE_SAVES_MILLIS = 60000l; private Context context = null; private UpdateNotifier updateNotifier; private final Set<String> distinctBluetoothAddresses = new HashSet<String>(); /** // It is very likely a crash if Bluetooth turns off and comes // back on in an extremely short interval. Testing on a Nexus 4 shows // that when the BluetoothService crashes, the time between the STATE_OFF // and the STATE_TURNING_ON ranges from 0ms-684ms // Out of 3614 samples: // 99.4% (3593) < 600 ms // 84.7% (3060) < 500 ms // So we will assume any power off sequence of < 600ms to be a crash // // While it is possible to manually turn Bluetooth off then back on in // about 600ms, but it is pretty hard to do. // */ private static final long SUSPICIOUSLY_SHORT_BLUETOOTH_OFF_INTERVAL_MILLIS = 600l; /** * The Bluedroid stack can only track only 1990 unique Bluetooth mac addresses without crashing */ private static final int BLUEDROID_MAX_BLUETOOTH_MAC_COUNT = 1990; /** * The discovery process will pare back the mac address list to 256, but more may * be found in the time we let the discovery process run, depending hon how many BLE * devices are around. */ private static final int BLUEDROID_POST_DISCOVERY_ESTIMATED_BLUETOOTH_MAC_COUNT = 400; /** * It takes a little over 2 seconds after discovery is started before the pared-down mac file * is written to persistent storage. We let discovery run for a few more seconds just to be * sure. */ private static final int TIME_TO_LET_DISCOVERY_RUN_MILLIS = 5000; /* if 0, it means forever */ /** * Constructor should be called only once per long-running process that does Bluetooth LE * scanning. Must call start() to make it do anything. * * @param context the Activity or Service that is doing the Bluetooth scanning */ public BluetoothCrashResolver(Context context) { this.context = context.getApplicationContext(); LogManager.d(TAG, "constructed"); loadState(); } /** * Starts looking for crashes of the Bluetooth LE system and taking proactive steps to stop * crashes from happening. Proactive steps require calls to notifyScannedDevice(Device device) * so that crashes can be predicted ahead of time. */ public void start() { IntentFilter filter = new IntentFilter(); filter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED); filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED); filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED); context.registerReceiver(receiver, filter); LogManager.d(TAG, "started listening for BluetoothAdapter events"); } /** * Stops looking for crashes. Does not need to be called in normal operations, but may be * useful for testing. */ public void stop() { context.unregisterReceiver(receiver); LogManager.d(TAG, "stopped listening for BluetoothAdapter events"); saveState(); } /** * Enable debug logging. By default no debug lines are logged. * @deprecated Since the default logger used by the android-beacon-library only logs warnings and * above, this method is no logger used. To log debug messages use the * {@link org.altbeacon.beacon.logging.Loggers#verboseLogger()} * @see org.altbeacon.beacon.logging.LogManager * @see org.altbeacon.beacon.logging.Loggers */ @Deprecated public void enableDebug() { } /** * Disable debug logging. * @deprecated Since the default logger used by the android-beacon-library only logs warnings and * above, this method is no logger used. To log debug messages use the * {@link org.altbeacon.beacon.logging.Loggers#verboseLogger()} * @see org.altbeacon.beacon.logging.LogManager * @see org.altbeacon.beacon.logging.Loggers */ @Deprecated public void disableDebug() { } /** * Call this method from your BluetoothAdapter.LeScanCallback method. * Doing so is optional, but if you do, this class will be able to count the number of * distinct Bluetooth devices scanned, and prevent crashes before they happen. * * This works very well if the app containing this class is the only one running bluetooth * LE scans on the device, or it is constantly doing scans (e.g. is in the foreground for * extended periods of time.) * * This will not work well if the application using this class is only scanning periodically * (e.g. when in the background to save battery) and another application is also scanning on * the same device, because this class will only get the counts from this application. * * Future augmentation of this class may improve this by somehow centralizing the list of * unique scanned devices. * * @param device */ @TargetApi(18) public void notifyScannedDevice(BluetoothDevice device, BluetoothAdapter.LeScanCallback scanner) { int oldSize, newSize; oldSize = distinctBluetoothAddresses.size(); synchronized(distinctBluetoothAddresses) { distinctBluetoothAddresses.add(device.getAddress()); } newSize = distinctBluetoothAddresses.size(); if (oldSize != newSize && newSize % 100 == 0) { LogManager.d(TAG, "Distinct Bluetooth devices seen: %s", distinctBluetoothAddresses.size()); } if (distinctBluetoothAddresses.size() > getCrashRiskDeviceCount()) { if (PREEMPTIVE_ACTION_ENABLED && !recoveryInProgress) { LogManager.w(TAG, "Large number of Bluetooth devices detected: %s Proactively " + "attempting to clear out address list to prevent a crash", distinctBluetoothAddresses.size()); LogManager.w(TAG, "Stopping LE Scan"); BluetoothAdapter.getDefaultAdapter().stopLeScan(scanner); startRecovery(); processStateChange(); } } } public void crashDetected() { if (android.os.Build.VERSION.SDK_INT < 18) { LogManager.d(TAG, "Ignoring crashes before API 18, because BLE is unsupported."); return; } LogManager.w(TAG, "BluetoothService crash detected"); if (distinctBluetoothAddresses.size() > 0) { LogManager.d(TAG, "Distinct Bluetooth devices seen at crash: %s", distinctBluetoothAddresses.size()); } long nowTimestamp = SystemClock.elapsedRealtime(); lastBluetoothCrashDetectionTime = nowTimestamp; detectedCrashCount++; if (recoveryInProgress) { LogManager.d(TAG, "Ignoring Bluetooth crash because recovery is already in progress."); } else { startRecovery(); } processStateChange(); } public long getLastBluetoothCrashDetectionTime() { return lastBluetoothCrashDetectionTime; } public int getDetectedCrashCount() { return detectedCrashCount; } public int getRecoveryAttemptCount() { return recoveryAttemptCount; } public boolean isLastRecoverySucceeded() { return lastRecoverySucceeded; } public boolean isRecoveryInProgress() { return recoveryInProgress; } public interface UpdateNotifier { public void dataUpdated(); } public void setUpdateNotifier(UpdateNotifier updateNotifier) { this.updateNotifier = updateNotifier; } /** Used to force a recovery operation */ public void forceFlush() { startRecovery(); processStateChange(); } private int getCrashRiskDeviceCount() { // 1990 distinct devices tracked by Bluedroid will cause a crash. But we don't know how many // devices Bluedroid is tracking, we only know how many we have seen, which will be smaller // than the number tracked by Bluedroid because the number we track does not include its // initial state. We therefore assume that there are some devices being tracked by Bluedroid // after a recovery operation or on startup return BLUEDROID_MAX_BLUETOOTH_MAC_COUNT-BLUEDROID_POST_DISCOVERY_ESTIMATED_BLUETOOTH_MAC_COUNT; } private void processStateChange() { if (updateNotifier != null) { updateNotifier.dataUpdated(); } if (SystemClock.elapsedRealtime() - lastStateSaveTime > MIN_TIME_BETWEEN_STATE_SAVES_MILLIS) { saveState(); } } @TargetApi(17) private void startRecovery() { // The discovery operation will start by clearing out the Bluetooth mac list to only the 256 // most recently seen BLE mac addresses. recoveryAttemptCount++; BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); LogManager.d(TAG, "about to check if discovery is active"); if (!adapter.isDiscovering()) { LogManager.w(TAG, "Recovery attempt started"); recoveryInProgress = true; discoveryStartConfirmed = false; LogManager.d(TAG, "about to command discovery"); if (!adapter.startDiscovery()) { LogManager.w(TAG, "Can't start discovery. Is Bluetooth turned on?"); } LogManager.d(TAG, "startDiscovery commanded. isDiscovering()=%s", adapter.isDiscovering()); // We don't actually need to do a discovery -- we just need to kick one off so the // mac list will be pared back to 256. Because discovery is an expensive operation in // terms of battery, we will cancel it. if (TIME_TO_LET_DISCOVERY_RUN_MILLIS > 0 ) { LogManager.d(TAG, "We will be cancelling this discovery in %s milliseconds.", TIME_TO_LET_DISCOVERY_RUN_MILLIS); cancelDiscovery(); } else { LogManager.d(TAG, "We will let this discovery run its course."); } } else { LogManager.w(TAG, "Already discovering. Recovery attempt abandoned."); } } private void finishRecovery() { LogManager.w(TAG, "Recovery attempt finished"); synchronized(distinctBluetoothAddresses) { distinctBluetoothAddresses.clear(); } recoveryInProgress = false; } private final BroadcastReceiver receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (action.equals(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)) { if (recoveryInProgress) { LogManager.d(TAG, "Bluetooth discovery finished"); finishRecovery(); } else { LogManager.d(TAG, "Bluetooth discovery finished (external)"); } } if (action.equals(BluetoothAdapter.ACTION_DISCOVERY_STARTED)) { if (recoveryInProgress) { discoveryStartConfirmed = true; LogManager.d(TAG, "Bluetooth discovery started"); } else { LogManager.d(TAG, "Bluetooth discovery started (external)"); } } if (action.equals(BluetoothAdapter.ACTION_STATE_CHANGED)) { final int state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR); switch (state) { case BluetoothAdapter.ERROR: LogManager.d(TAG, "Bluetooth state is ERROR"); break; case BluetoothAdapter.STATE_OFF: LogManager.d(TAG, "Bluetooth state is OFF"); lastBluetoothOffTime = SystemClock.elapsedRealtime(); break; case BluetoothAdapter.STATE_TURNING_OFF: break; case BluetoothAdapter.STATE_ON: LogManager.d(TAG, "Bluetooth state is ON"); LogManager.d(TAG, "Bluetooth was turned off for %s milliseconds", lastBluetoothTurningOnTime - lastBluetoothOffTime); if (lastBluetoothTurningOnTime - lastBluetoothOffTime < SUSPICIOUSLY_SHORT_BLUETOOTH_OFF_INTERVAL_MILLIS) { crashDetected(); } break; case BluetoothAdapter.STATE_TURNING_ON: lastBluetoothTurningOnTime = SystemClock.elapsedRealtime(); LogManager.d(TAG, "Bluetooth state is TURNING_ON"); break; } } } }; private void saveState() { FileOutputStream outputStream; OutputStreamWriter writer = null; lastStateSaveTime = SystemClock.elapsedRealtime(); try { outputStream = context.openFileOutput(DISTINCT_BLUETOOTH_ADDRESSES_FILE, Context.MODE_PRIVATE); writer = new OutputStreamWriter(outputStream); writer.write(lastBluetoothCrashDetectionTime+"\n"); writer.write(detectedCrashCount+"\n"); writer.write(recoveryAttemptCount+"\n"); writer.write(lastRecoverySucceeded ? "1\n" : "0\n"); synchronized (distinctBluetoothAddresses) { for (String mac : distinctBluetoothAddresses) { writer.write(mac); writer.write("\n"); } } } catch (IOException e) { LogManager.w(TAG, "Can't write macs to %s", DISTINCT_BLUETOOTH_ADDRESSES_FILE); } finally { if (writer != null) { try { writer.close(); } catch (IOException e1) { } } } LogManager.d(TAG, "Wrote %s Bluetooth addresses", distinctBluetoothAddresses.size()); } private void loadState() { FileInputStream inputStream; BufferedReader reader = null; try { inputStream = context.openFileInput(DISTINCT_BLUETOOTH_ADDRESSES_FILE); reader = new BufferedReader(new InputStreamReader(inputStream)); String line; line = reader.readLine(); if (line != null) { lastBluetoothCrashDetectionTime = Long.parseLong(line); } line = reader.readLine(); if (line != null) { detectedCrashCount = Integer.parseInt(line); } line = reader.readLine(); if (line != null) { recoveryAttemptCount = Integer.parseInt(line); } line = reader.readLine(); if (line != null) { lastRecoverySucceeded = false; if (line.equals("1")) { lastRecoverySucceeded = true; } } String mac; while ((mac = reader.readLine()) != null) { distinctBluetoothAddresses.add(mac); } } catch (IOException e) { LogManager.w(TAG, "Can't read macs from %s", DISTINCT_BLUETOOTH_ADDRESSES_FILE); } catch (NumberFormatException e) { LogManager.w(TAG, "Can't parse file %s", DISTINCT_BLUETOOTH_ADDRESSES_FILE); } finally { if (reader != null) { try { reader.close(); } catch (IOException e1) { } } } LogManager.d(TAG, "Read %s Bluetooth addresses", distinctBluetoothAddresses.size()); } private void cancelDiscovery() { try { Thread.sleep(TIME_TO_LET_DISCOVERY_RUN_MILLIS); if (!discoveryStartConfirmed) { LogManager.w(TAG, "BluetoothAdapter.ACTION_DISCOVERY_STARTED never received. Recovery may fail."); } final BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); if (adapter.isDiscovering()) { LogManager.d(TAG, "Cancelling discovery"); adapter.cancelDiscovery(); } else { LogManager.d(TAG, "Discovery not running. Won't cancel it"); } } catch (InterruptedException e) { LogManager.d(TAG, "DiscoveryCanceller sleep interrupted."); } } }