package com.radiusnetworks.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.AsyncTask; import android.util.Log; import com.sensorberg.sdk.Logger; 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.Date; import java.util.HashSet; import java.util.Set; import lombok.Getter; import lombok.Setter; /** * * Copyright 2014 Radius Networks * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * 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. iBeacon-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. */ @SuppressWarnings({"ConstantConditions", "WeakerAccess", "PointlessBooleanExpression"}) @TargetApi(5) public class BluetoothCrashResolver { private static final String TAG = "BluetoothCrashResolver"; private static final boolean PREEMPTIVE_ACTION_ENABLED = true; private boolean debugEnabled = false; /** * 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; @Getter private long lastBluetoothCrashDetectionTime = 0l; @Getter private int detectedCrashCount = 0; @Getter 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; @Setter private UpdateNotifier updateNotifier; private final Set<String> distinctBluetoothAddresses = new HashSet<>(); private final DiscoveryCanceller discoveryCanceller = new DiscoveryCanceller(); /** // 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(); if (isDebugEnabled()) Log.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); if (isDebugEnabled()) Log.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); if (isDebugEnabled()) Log.d(TAG, "stopped listening for BluetoothAdapter events"); saveState(); } /** * Enable debug logging. By default no debug lines are logged. */ public void enableDebug() { debugEnabled = true; } /** * Disable debug logging */ public void disableDebug() { debugEnabled = false; } /** * 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 * disctinct 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. */ @TargetApi(18) public void notifyScannedDevice(BluetoothDevice device, BluetoothAdapter.LeScanCallback scanner) { int oldSize = 0, newSize; if (isDebugEnabled()) oldSize = distinctBluetoothAddresses.size(); distinctBluetoothAddresses.add(device.getAddress()); if (isDebugEnabled()) { newSize = distinctBluetoothAddresses.size(); if (oldSize != newSize && newSize % 100 == 0) { if (isDebugEnabled()) Log.d(TAG, "Distinct bluetooth devices seen: "+distinctBluetoothAddresses.size()); } } if (distinctBluetoothAddresses.size() > getCrashRiskDeviceCount()) { if (PREEMPTIVE_ACTION_ENABLED && !recoveryInProgress) { Logger.log.verbose("Large number of bluetooth devices detected: "+distinctBluetoothAddresses.size()+" Proactively attempting to clear out address list to prevent a crash"); Logger.log.verbose("Stopping LE Scan"); //noinspection deprecation old API compatability BluetoothAdapter.getDefaultAdapter().stopLeScan(scanner); startRecovery(); processStateChange(); } } } public void crashDetected() { if (android.os.Build.VERSION.SDK_INT < 18) { if (isDebugEnabled()) Log.d(TAG, "Ignoring crashes before SDK 18, because BLE is unsupported."); return; } Logger.log.verbose("BluetoothService crash detected"); if (distinctBluetoothAddresses.size() > 0) { if (isDebugEnabled()) Log.d(TAG, "Distinct bluetooth devices seen at crash: "+distinctBluetoothAddresses.size()); } lastBluetoothCrashDetectionTime = new Date().getTime(); detectedCrashCount++; if (recoveryInProgress) { if (isDebugEnabled()) Log.d(TAG, "Ignoring bluetooth crash because recovery is already in progress."); } else { startRecovery(); } processStateChange(); } public boolean isLastRecoverySucceeded() { return lastRecoverySucceeded; } public boolean isRecoveryInProgress() { return recoveryInProgress; } public interface UpdateNotifier { void dataUpdated(); } /** Used to force a recovery operation */ public void forceFlush() { startRecovery(); processStateChange(); } private boolean isDebugEnabled() { return debugEnabled; } 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 (new Date().getTime() - 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(); if (isDebugEnabled()) Log.d(TAG, "about to check if discovery is active"); if (!adapter.isDiscovering()) { Logger.log.verbose("Recovery attempt started"); recoveryInProgress = true; discoveryStartConfirmed = false; if (isDebugEnabled()) Log.d(TAG, "about to command discovery"); if (!adapter.startDiscovery()) { Logger.log.verbose("Can't start discovery. Is bluetooth turned on?"); } if (isDebugEnabled()) Log.d(TAG, "startDiscovery commanded. isDiscovering()="+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 ) { if (isDebugEnabled()) Log.d(TAG, "We will be cancelling this discovery in "+TIME_TO_LET_DISCOVERY_RUN_MILLIS+" milliseconds."); discoveryCanceller.doInBackground(); } else { Logger.log.verbose("We will let this discovery run its course."); } } else { Logger.log.verbose("Already discovering.Recovery attempt abandoned."); } } private void finishRecovery() { Logger.log.verbose("Recovery attempt finished"); 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) { if (isDebugEnabled()) Log.d(TAG, "Bluetooth discovery finished"); finishRecovery(); } else { if (isDebugEnabled()) Log.d(TAG, "Bluetooth discovery finished (external)"); } } if (action.equals(BluetoothAdapter.ACTION_DISCOVERY_STARTED)) { if (recoveryInProgress) { discoveryStartConfirmed = true; if (isDebugEnabled()) Log.d(TAG, "Bluetooth discovery started"); } else { if (isDebugEnabled()) Log.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: if (isDebugEnabled()) Log.d(TAG, "Bluetooth state is ERROR"); break; case BluetoothAdapter.STATE_OFF: if (isDebugEnabled()) Log.d(TAG, "Bluetooth state is OFF"); lastBluetoothOffTime = new Date().getTime(); break; case BluetoothAdapter.STATE_TURNING_OFF: break; case BluetoothAdapter.STATE_ON: if (isDebugEnabled()) Log.d(TAG, "Bluetooth state is ON"); if (isDebugEnabled()) Log.d(TAG, "Bluetooth was turned off for "+(lastBluetoothTurningOnTime - lastBluetoothOffTime)+" milliseconds"); if (lastBluetoothTurningOnTime - lastBluetoothOffTime < SUSPICIOUSLY_SHORT_BLUETOOTH_OFF_INTERVAL_MILLIS) { crashDetected(); } break; case BluetoothAdapter.STATE_TURNING_ON: lastBluetoothTurningOnTime = new Date().getTime(); if (isDebugEnabled()) Log.d(TAG, "Bluetooth state is TURNING_ON"); break; } } } }; private void saveState() { FileOutputStream outputStream; OutputStreamWriter writer = null; lastStateSaveTime = new Date().getTime(); 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) { Logger.log.verbose("Can't write macs to " + DISTINCT_BLUETOOTH_ADDRESSES_FILE); } finally { if (writer != null) { try { writer.close(); } catch (IOException e1) { Logger.log.logError("Cant´ write macs", e1); } } } if (isDebugEnabled()) Log.d(TAG, "Wrote "+distinctBluetoothAddresses.size()+" bluetooth addresses"); } 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 = line.equals("1"); } String mac; while ((mac = reader.readLine()) != null) { distinctBluetoothAddresses.add(mac); } } catch (IOException e) { Logger.log.verbose("Can't read macs from " + DISTINCT_BLUETOOTH_ADDRESSES_FILE); } catch (NumberFormatException e) { Logger.log.verbose("Can't parse file " + DISTINCT_BLUETOOTH_ADDRESSES_FILE); } finally { if (reader != null) { try { reader.close(); } catch (IOException e1) { Logger.log.logError("close mac reader", e1); } } } if (isDebugEnabled()) Log.d(TAG, "Read "+distinctBluetoothAddresses.size()+" bluetooth addresses"); } private class DiscoveryCanceller extends AsyncTask<Void, Void, Void> { @Override protected Void doInBackground(Void... params) { try { Thread.sleep(TIME_TO_LET_DISCOVERY_RUN_MILLIS); if (!discoveryStartConfirmed) { Logger.log.verbose("BluetoothAdapter.ACTION_DISCOVERY_STARTED never received. Recovery may fail."); } final BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); if (adapter.isDiscovering()) { if (isDebugEnabled()) Log.d(TAG, "Cancelling discovery"); adapter.cancelDiscovery(); } else { if (isDebugEnabled()) Log.d(TAG, "Discovery not running. Won't cancel it"); } } catch (InterruptedException e) { if (isDebugEnabled()) Log.d(TAG, "DiscoveryCanceller sleep interrupted."); } return null; } @Override protected void onPostExecute(Void result) { } @Override protected void onPreExecute() { } @Override protected void onProgressUpdate(Void... values) { } } }