diff --git a/CHANGELOG.md b/CHANGELOG.md index 187b1ae7..d0706396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Add `autoConnect` parameter to `connect()` method for automatic reconnection support on Android and iOS/macOS * Add `serviceData` in `BleDevice` * Add `AndroidScanMode` and `reportDelayMillis` to `AndroidOptions` for scanning +* Add support for BLE scanning from background services (ForegroundTask) on Android. `PermissionHandler` is now activity-aware and succeeds silently if permissions are already granted when no activity is available ## 1.1.0 * Add readRssi method diff --git a/README.md b/README.md index 5faefba2..d1e7289f 100644 --- a/README.md +++ b/README.md @@ -657,6 +657,23 @@ The `withAndroidFineLocation` parameter in `requestPermissions()` controls locat - Location permission is always requested if declared in your manifest (required for BLE scanning) - The `withAndroidFineLocation` parameter is ignored +#### Background Scanning (ForegroundTask) + +Universal BLE supports BLE scanning from background services (e.g., using `flutter_foreground_task` or similar packages) on Android. When running in a background context without an Activity: + +- **If permissions are already granted**: Scanning works normally +- **If permissions are not granted**: An error is thrown with the message "Permissions not granted and activity is not available to request them" + +**Best Practice**: Request permissions while your app is in the foreground before starting any background BLE operations: + +```dart +// Request permissions in foreground (e.g., during app setup) +await UniversalBle.requestPermissions(); + +// Later, in your ForegroundTask, scanning will work if permissions were granted +await UniversalBle.startScan(); +``` + ### iOS / macOS Add `NSBluetoothPeripheralUsageDescription` and `NSBluetoothAlwaysUsageDescription` to Info.plist of your iOS and macOS app. diff --git a/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt b/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt index 9bac59a6..2245ba56 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt @@ -19,9 +19,17 @@ private const val TAG = "PermissionHandler" */ class PermissionHandler( private val context: Context, - private val activity: Activity, private val requestCode: Int, ) { + private var activity: Activity? = null + + /** + * Attaches or detaches an activity to the permission handler. + * Call with an Activity when attached, or null when detached. + */ + fun attachActivity(activity: Activity?) { + this.activity = activity + } private var permissionRequestCallback: ((Result) -> Unit)? = null /** @@ -39,6 +47,10 @@ class PermissionHandler( /** * Requests the required Bluetooth permissions based on the manifest and Android version. * + * If activity is not available (e.g., running in a ForegroundTask), this method will + * check if all required permissions are already granted and succeed silently if so. + * It will only fail if permissions are needed but cannot be requested due to missing activity. + * * @param callback Called with the result of the permission request */ fun requestPermissions( @@ -52,7 +64,7 @@ class PermissionHandler( return } - // Check which permissions are declared in manifest + // Check which permissions need to be requested val permissionsToRequest = getRequiredPermissions(withFineLocation) if (permissionsToRequest.isEmpty()) { @@ -61,6 +73,22 @@ class PermissionHandler( return } + // Permissions need to be requested - check if activity is available + val currentActivity = activity + if (currentActivity == null) { + // No activity available and permissions not granted + callback( + Result.failure( + createFlutterError( + UniversalBleErrorCode.FAILED, + "Permissions not granted and activity is not available to request them. " + + "Please request permissions while the app is in foreground." + ) + ) + ) + return + } + // Check if we already have a pending permission request if (permissionRequestCallback != null) { callback( @@ -76,7 +104,7 @@ class PermissionHandler( permissionRequestCallback = callback ActivityCompat.requestPermissions( - activity, + currentActivity, permissionsToRequest.toTypedArray(), requestCode ) 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 7a07623a..8e859421 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -68,6 +68,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), callbackChannel = UniversalBleCallbackChannel(flutterPluginBinding.binaryMessenger) context = flutterPluginBinding.applicationContext mainThreadHandler = Handler(Looper.getMainLooper()) + permissionHandler = PermissionHandler(context, permissionRequestCode) bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager safeScanner = SafeScanner(bluetoothManager) @@ -86,6 +87,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), context.unregisterReceiver(broadcastReceiver) callbackChannel = null mainThreadHandler = null + permissionHandler = null } override fun getBluetoothAvailabilityState(callback: (Result) -> Unit) { @@ -1304,20 +1306,23 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity - permissionHandler = PermissionHandler(context, binding.activity, permissionRequestCode) binding.addActivityResultListener(this) binding.addRequestPermissionsResultListener(this) + permissionHandler?.attachActivity(binding.activity) } override fun onDetachedFromActivity() { activity = null - permissionHandler = null + permissionHandler?.attachActivity(null) + } + + override fun onDetachedFromActivityForConfigChanges() { + // Activity will be reattached, keep the reference } - override fun onDetachedFromActivityForConfigChanges() {} override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { activity = binding.activity - permissionHandler = PermissionHandler(context, binding.activity, permissionRequestCode) + permissionHandler?.attachActivity(binding.activity) } override fun onRequestPermissionsResult( diff --git a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart index 92811b7f..ac664e37 100644 --- a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart +++ b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart @@ -209,7 +209,10 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform { } Future _ensureInitialized(PlatformConfig? platformConfig) async { - // Check bluetooth availability on Apple and Android + // Request permissions on Apple and Android + // On Android: If activity is available, requests permissions if needed. + // If activity is not available (e.g., ForegroundTask), succeeds + // if permissions are already granted, fails otherwise. if (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS) {