From 506f08758804f622c57ce5fce6eaf773c88fa62c Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Mon, 2 Feb 2026 16:41:36 +0100 Subject: [PATCH 1/2] - Added support for BLE scanning from background services on Android. - Enhanced `PermissionHandler` to be activity-aware, allowing silent success for already granted permissions when no activity is available. - Updated README with best practices for requesting permissions in foreground before background operations. --- CHANGELOG.md | 1 + README.md | 17 +++++++ .../universal_ble/PermissionHandler.kt | 47 +++++++++++++++++-- .../universal_ble/UniversalBlePlugin.kt | 13 +++-- .../universal_ble_pigeon_channel.dart | 5 +- 5 files changed, 74 insertions(+), 9 deletions(-) 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..8f056a33 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt @@ -8,6 +8,8 @@ import android.util.Log import androidx.core.app.ActivityCompat import android.Manifest import android.annotation.SuppressLint +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding private const val TAG = "PermissionHandler" @@ -19,9 +21,9 @@ private const val TAG = "PermissionHandler" */ class PermissionHandler( private val context: Context, - private val activity: Activity, private val requestCode: Int, -) { +) : ActivityAware { + private var activity: Activity? = null private var permissionRequestCallback: ((Result) -> Unit)? = null /** @@ -39,6 +41,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 +58,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 +67,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 +98,7 @@ class PermissionHandler( permissionRequestCallback = callback ActivityCompat.requestPermissions( - activity, + currentActivity, permissionsToRequest.toTypedArray(), requestCode ) @@ -258,4 +280,21 @@ class PermissionHandler( } return null } + + // ActivityAware interface implementation + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivity() { + activity = null + } + + override fun onDetachedFromActivityForConfigChanges() { + // Activity will be reattached, so we don't need to clear it here + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + } } 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..26457d5d 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?.onAttachedToActivity(binding) } override fun onDetachedFromActivity() { activity = null - permissionHandler = null + permissionHandler?.onDetachedFromActivity() + } + + override fun onDetachedFromActivityForConfigChanges() { + permissionHandler?.onDetachedFromActivityForConfigChanges() } - override fun onDetachedFromActivityForConfigChanges() {} override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { activity = binding.activity - permissionHandler = PermissionHandler(context, binding.activity, permissionRequestCode) + permissionHandler?.onReattachedToActivityForConfigChanges(binding) } 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) { From 0e69ba8ac75e82888549bf517c66b3adb5ff5f3b Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Mon, 2 Feb 2026 16:49:20 +0100 Subject: [PATCH 2/2] Simplify implementation --- .../universal_ble/PermissionHandler.kt | 29 ++++++------------- .../universal_ble/UniversalBlePlugin.kt | 8 ++--- 2 files changed, 13 insertions(+), 24 deletions(-) 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 8f056a33..2245ba56 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt @@ -8,8 +8,6 @@ import android.util.Log import androidx.core.app.ActivityCompat import android.Manifest import android.annotation.SuppressLint -import io.flutter.embedding.engine.plugins.activity.ActivityAware -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding private const val TAG = "PermissionHandler" @@ -22,8 +20,16 @@ private const val TAG = "PermissionHandler" class PermissionHandler( private val context: Context, private val requestCode: Int, -) : ActivityAware { +) { 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 /** @@ -280,21 +286,4 @@ class PermissionHandler( } return null } - - // ActivityAware interface implementation - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - activity = binding.activity - } - - override fun onDetachedFromActivity() { - activity = null - } - - override fun onDetachedFromActivityForConfigChanges() { - // Activity will be reattached, so we don't need to clear it here - } - - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activity = binding.activity - } } 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 26457d5d..8e859421 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -1308,21 +1308,21 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), activity = binding.activity binding.addActivityResultListener(this) binding.addRequestPermissionsResultListener(this) - permissionHandler?.onAttachedToActivity(binding) + permissionHandler?.attachActivity(binding.activity) } override fun onDetachedFromActivity() { activity = null - permissionHandler?.onDetachedFromActivity() + permissionHandler?.attachActivity(null) } override fun onDetachedFromActivityForConfigChanges() { - permissionHandler?.onDetachedFromActivityForConfigChanges() + // Activity will be reattached, keep the reference } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { activity = binding.activity - permissionHandler?.onReattachedToActivityForConfigChanges(binding) + permissionHandler?.attachActivity(binding.activity) } override fun onRequestPermissionsResult(