diff --git a/android/src/main/kotlin/com/navideck/universal_ble/SafeScanner.kt b/android/src/main/kotlin/com/navideck/universal_ble/SafeScanner.kt new file mode 100644 index 0000000..e0458a8 --- /dev/null +++ b/android/src/main/kotlin/com/navideck/universal_ble/SafeScanner.kt @@ -0,0 +1,79 @@ +package com.navideck.universal_ble + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanSettings +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import java.util.LinkedList + +private const val NUM_SCAN_DURATIONS_KEPT = 5 +private const val EXCESSIVE_SCANNING_PERIOD_MS = 30 * 1000L +private const val TAG = "UniversalBlePlugin" + +/** + * A safe wrapper for Bluetooth LE scanning operations that prevents excessive scanning. + * + * This class manages BLE scanning while adhering to Android's scanning frequency limits by: + * - Tracking scan start times over a 30-second window + * - Limiting to 5 scan operations within this window + * - Automatically scheduling delayed scans when frequency limits are exceeded + * - Providing safe start/stop scan operations with error handling + * + * The scanner will automatically delay new scan requests if the frequency limit is reached, + * and will retry once sufficient time has passed. This helps prevent scan failure errors + * and ensures compliance with Android's scanning restrictions. + * + * @property bluetoothManager The system's BluetoothManager used for scanning operations + */ +@SuppressLint("MissingPermission") +class SafeScanner(private val bluetoothManager: BluetoothManager) { + private val handler = Handler(Looper.myLooper()!!) + private val startTimes = LinkedList() + private var awaitingScan = false + + fun startScan(filters: List, settings: ScanSettings, callback: ScanCallback) { + val now = System.currentTimeMillis() + startTimes.removeAll { now - it > EXCESSIVE_SCANNING_PERIOD_MS } + + if (startTimes.size >= NUM_SCAN_DURATIONS_KEPT) { + if (awaitingScan) { + Log.e(TAG, "startScan: too frequent, awaiting scan..") + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + callback.onScanFailed(ScanCallback.SCAN_FAILED_SCANNING_TOO_FREQUENTLY) + } + + awaitingScan = true + val delay = startTimes.first + EXCESSIVE_SCANNING_PERIOD_MS - now + 2_000 + Log.e(TAG, "startScan: too frequent, schedule auto-start after $delay ms $startTimes") + + handler.postDelayed({ + Log.d(TAG, "Retrying scan after delay") + awaitingScan = false + startScan(filters, settings, callback) + }, delay) + } else { + awaitingScan = false + startTimes.addLast(now) + try { + bluetoothManager.adapter.bluetoothLeScanner?.startScan(filters, settings, callback) + } catch (e: Exception) { + Log.e(TAG, "Failed to start Scan : $e") + } + } + } + + fun stopScan(callback: ScanCallback) { + awaitingScan = false + handler.removeCallbacksAndMessages(null) + bluetoothManager.adapter.bluetoothLeScanner?.stopScan(callback) + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt index eb76716..7304980 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Activity import android.bluetooth.* import android.bluetooth.BluetoothDevice.BOND_BONDED +import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult @@ -37,6 +38,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), private lateinit var context: Context private var activity: Activity? = null private lateinit var bluetoothManager: BluetoothManager + private lateinit var safeScanner: SafeScanner private val cachedServicesMap = mutableMapOf>() private val devicesStateMap = mutableMapOf() private val universalBleFilterUtil = UniversalBleFilterUtil() @@ -50,13 +52,13 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), private val subscriptionResultFutureList = mutableListOf() private val pairResultFutures = mutableMapOf) -> Unit>() - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { UniversalBlePlatformChannel.setUp(flutterPluginBinding.binaryMessenger, this) callbackChannel = UniversalBleCallbackChannel(flutterPluginBinding.binaryMessenger) context = flutterPluginBinding.applicationContext mainThreadHandler = Handler(Looper.getMainLooper()) bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + safeScanner = SafeScanner(bluetoothManager) val intentFilter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) intentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) @@ -75,7 +77,6 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), mainThreadHandler = null } - override fun getBluetoothAvailabilityState(callback: (Result) -> Unit) { callback( Result.success( @@ -132,7 +133,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), scanFilters = filter?.toScanFilters(filterServices) ?: emptyList() } - bluetoothManager.adapter.bluetoothLeScanner?.startScan( + safeScanner.startScan( scanFilters, settings, scanCallback ) } catch (e: Exception) { @@ -150,7 +151,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), "Bluetooth not enabled", ) // check if already scanning - bluetoothManager.adapter.bluetoothLeScanner?.stopScan(scanCallback) + safeScanner.stopScan(scanCallback) } override fun connect(deviceId: String) {