From 506f08758804f622c57ce5fce6eaf773c88fa62c Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Mon, 2 Feb 2026 16:41:36 +0100 Subject: [PATCH 1/4] - 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/4] 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( From 941a94ed36cd0257341e4e4e13e426b29c19a1ae Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Mon, 2 Feb 2026 17:10:48 +0100 Subject: [PATCH 3/4] Add Background Device Monitor feature to the example app that demonstrates BLE scanning from a foreground service on Android. --- .../android/app/src/main/AndroidManifest.xml | 11 + .../lib/background/ble_monitor_service.dart | 223 ++++++++++++++++++ example/lib/data/storage_service.dart | 27 +++ example/lib/home/scanner_screen.dart | 78 ++++++ .../lib/home/widgets/scanned_item_widget.dart | 136 +++++++++-- example/lib/main.dart | 4 + example/pubspec.lock | 8 + example/pubspec.yaml | 1 + 8 files changed, 467 insertions(+), 21 deletions(-) create mode 100644 example/lib/background/ble_monitor_service.dart diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 30fcaa85..49c0b69c 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,11 @@ + + + + + + + + diff --git a/example/lib/background/ble_monitor_service.dart b/example/lib/background/ble_monitor_service.dart new file mode 100644 index 00000000..ac6f0046 --- /dev/null +++ b/example/lib/background/ble_monitor_service.dart @@ -0,0 +1,223 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:universal_ble/universal_ble.dart'; + +/// The callback function should always be a top-level or static function. +@pragma('vm:entry-point') +void startBleMonitorCallback() { + FlutterForegroundTask.setTaskHandler(BleMonitorTaskHandler()); +} + +/// Task handler for background BLE device monitoring. +/// Periodically scans for monitored devices and updates the notification. +class BleMonitorTaskHandler extends TaskHandler { + static const String _monitoredDevicesKey = 'monitored_devices'; + static const int _scanDurationSeconds = 5; + + List _monitoredDeviceIds = []; + Set _foundDevices = {}; + StreamSubscription? _scanSubscription; + bool _isScanning = false; + + @override + Future onStart(DateTime timestamp, TaskStarter starter) async { + // Load monitored devices from SharedPreferences + final prefs = await SharedPreferences.getInstance(); + _monitoredDeviceIds = prefs.getStringList(_monitoredDevicesKey) ?? []; + + // Set up scan result listener + _scanSubscription = UniversalBle.scanStream.listen(_onScanResult); + + FlutterForegroundTask.updateService( + notificationTitle: 'BLE Monitor Active', + notificationText: 'Monitoring ${_monitoredDeviceIds.length} device(s)', + ); + } + + void _onScanResult(BleDevice device) { + if (_monitoredDeviceIds.contains(device.deviceId)) { + _foundDevices.add(device.deviceId); + } + } + + @override + void onRepeatEvent(DateTime timestamp) async { + if (_isScanning) return; + + // Reload monitored devices in case they changed + final prefs = await SharedPreferences.getInstance(); + _monitoredDeviceIds = prefs.getStringList(_monitoredDevicesKey) ?? []; + + if (_monitoredDeviceIds.isEmpty) { + FlutterForegroundTask.updateService( + notificationTitle: 'BLE Monitor Active', + notificationText: 'No devices to monitor', + ); + return; + } + + _isScanning = true; + _foundDevices.clear(); + + try { + // Start scanning + await UniversalBle.startScan(); + + // Wait for scan duration + await Future.delayed(const Duration(seconds: _scanDurationSeconds)); + + // Stop scanning + await UniversalBle.stopScan(); + } catch (e) { + // Handle scan errors (e.g., Bluetooth off) + FlutterForegroundTask.updateService( + notificationTitle: 'BLE Monitor', + notificationText: 'Scan error: ${e.toString().substring(0, 50)}', + ); + _isScanning = false; + return; + } + + _isScanning = false; + + // Update notification with results + final foundCount = _foundDevices.length; + final totalCount = _monitoredDeviceIds.length; + + String notificationText; + if (foundCount == 0) { + notificationText = 'Monitoring $totalCount device(s) - none nearby'; + } else if (foundCount == totalCount) { + notificationText = 'All $totalCount monitored device(s) found nearby'; + } else { + notificationText = '$foundCount of $totalCount device(s) found nearby'; + } + + FlutterForegroundTask.updateService( + notificationTitle: 'BLE Monitor Active', + notificationText: notificationText, + ); + + // Send data to main isolate + FlutterForegroundTask.sendDataToMain({ + 'foundDevices': _foundDevices.toList(), + 'monitoredDevices': _monitoredDeviceIds, + 'timestamp': timestamp.millisecondsSinceEpoch, + }); + } + + @override + Future onDestroy(DateTime timestamp, bool isTimeout) async { + _scanSubscription?.cancel(); + _scanSubscription = null; + + if (_isScanning) { + try { + await UniversalBle.stopScan(); + } catch (_) {} + } + } + + @override + void onReceiveData(Object data) { + // Handle data from main isolate if needed + } + + @override + void onNotificationButtonPressed(String id) { + if (id == 'stop') { + FlutterForegroundTask.stopService(); + } + } + + @override + void onNotificationPressed() { + // Launch app when notification is pressed + FlutterForegroundTask.launchApp(); + } + + @override + void onNotificationDismissed() { + // Notification dismissed - service continues running + } +} + +/// Helper class to manage the BLE monitor service from the UI. +class BleMonitorManager { + BleMonitorManager._(); + static final BleMonitorManager instance = BleMonitorManager._(); + + /// Initialize the foreground task service. + void init() { + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'ble_monitor_channel', + channelName: 'BLE Device Monitor', + channelDescription: 'Monitors BLE devices in the background', + onlyAlertOnce: true, + ), + iosNotificationOptions: const IOSNotificationOptions( + showNotification: false, + playSound: false, + ), + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.repeat(30000), // 30 seconds + autoRunOnBoot: false, + autoRunOnMyPackageReplaced: false, + allowWakeLock: true, + allowWifiLock: true, + ), + ); + } + + /// Check if the monitor service is running. + Future isRunning() async { + return await FlutterForegroundTask.isRunningService; + } + + /// Start the background monitor service. + Future start() async { + // Check if already running + if (await FlutterForegroundTask.isRunningService) { + await FlutterForegroundTask.restartService(); + return; + } + + // Request notification permission on Android 13+ + if (Platform.isAndroid) { + final notificationPermission = + await FlutterForegroundTask.checkNotificationPermission(); + if (notificationPermission != NotificationPermission.granted) { + await FlutterForegroundTask.requestNotificationPermission(); + } + } + + await FlutterForegroundTask.startService( + serviceId: 256, + notificationTitle: 'BLE Monitor Starting...', + notificationText: 'Initializing background scan', + notificationButtons: [ + const NotificationButton(id: 'stop', text: 'Stop'), + ], + callback: startBleMonitorCallback, + ); + } + + /// Stop the background monitor service. + Future stop() async { + await FlutterForegroundTask.stopService(); + } + + /// Add a callback to receive data from the background service. + void addDataCallback(void Function(Object data) callback) { + FlutterForegroundTask.addTaskDataCallback(callback); + } + + /// Remove a data callback. + void removeDataCallback(void Function(Object data) callback) { + FlutterForegroundTask.removeTaskDataCallback(callback); + } +} diff --git a/example/lib/data/storage_service.dart b/example/lib/data/storage_service.dart index c52f3a37..6343402e 100644 --- a/example/lib/data/storage_service.dart +++ b/example/lib/data/storage_service.dart @@ -19,4 +19,31 @@ class StorageService { List getFavoriteServices() => _preferences.getStringList('favorite_services') ?? []; + + // Monitored devices for background scanning + static const String _monitoredDevicesKey = 'monitored_devices'; + + Future setMonitoredDevices(List deviceIds) async { + await _preferences.setStringList(_monitoredDevicesKey, deviceIds); + } + + List getMonitoredDevices() => + _preferences.getStringList(_monitoredDevicesKey) ?? []; + + Future addMonitoredDevice(String deviceId) async { + final devices = getMonitoredDevices(); + if (!devices.contains(deviceId)) { + devices.add(deviceId); + await setMonitoredDevices(devices); + } + } + + Future removeMonitoredDevice(String deviceId) async { + final devices = getMonitoredDevices(); + devices.remove(deviceId); + await setMonitoredDevices(devices); + } + + bool isDeviceMonitored(String deviceId) => + getMonitoredDevices().contains(deviceId); } diff --git a/example/lib/home/scanner_screen.dart b/example/lib/home/scanner_screen.dart index ae52dc60..725ca1bc 100644 --- a/example/lib/home/scanner_screen.dart +++ b/example/lib/home/scanner_screen.dart @@ -1,11 +1,14 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:universal_ble/universal_ble.dart'; +import 'package:universal_ble_example/background/ble_monitor_service.dart'; import 'package:universal_ble_example/data/company_identifier_service.dart'; import 'package:universal_ble_example/data/mock_universal_ble.dart'; +import 'package:universal_ble_example/data/storage_service.dart'; import 'package:universal_ble_example/home/widgets/ble_availability_icon.dart'; import 'package:universal_ble_example/home/widgets/drawer.dart'; import 'package:universal_ble_example/home/widgets/scan_filter_widget.dart'; @@ -24,6 +27,7 @@ class _ScannerScreenState extends State { final _bleDevices = []; final _hiddenDevices = []; bool _isScanning = false; + bool _isMonitorRunning = false; QueueType _queueType = QueueType.global; TextEditingController servicesFilterController = TextEditingController(); TextEditingController namePrefixController = TextEditingController(); @@ -36,6 +40,9 @@ class _ScannerScreenState extends State { ScanFilter? scanFilter; final Map _isExpanded = {}; + // Background monitor support (Android only) + bool get _supportsBackgroundMonitor => !kIsWeb && Platform.isAndroid; + @override void initState() { super.initState(); @@ -70,6 +77,54 @@ class _ScannerScreenState extends State { _searchFilterController.addListener(() { _saveScanFilters(); }); + + // Initialize background monitor (Android only) + if (_supportsBackgroundMonitor) { + _initBackgroundMonitor(); + } + } + + Future _initBackgroundMonitor() async { + BleMonitorManager.instance.init(); + final isRunning = await BleMonitorManager.instance.isRunning(); + if (mounted) { + setState(() => _isMonitorRunning = isRunning); + } + // Listen for data from background service + BleMonitorManager.instance.addDataCallback(_onBackgroundMonitorData); + } + + void _onBackgroundMonitorData(Object data) { + if (data is Map) { + final foundDevices = data['foundDevices'] as List?; + if (foundDevices != null && mounted) { + debugPrint('Background monitor found: ${foundDevices.length} devices'); + } + } + } + + Future _toggleBackgroundMonitor() async { + final monitoredDevices = StorageService.instance.getMonitoredDevices(); + if (monitoredDevices.isEmpty) { + showSnackbar('No devices to monitor. Long-press a device to add it.'); + return; + } + + if (_isMonitorRunning) { + await BleMonitorManager.instance.stop(); + setState(() => _isMonitorRunning = false); + showSnackbar('Background monitor stopped'); + } else { + await BleMonitorManager.instance.start(); + // Check if service started successfully + final isRunning = await BleMonitorManager.instance.isRunning(); + if (isRunning) { + setState(() => _isMonitorRunning = true); + showSnackbar('Background monitor started'); + } else { + showSnackbar('Failed to start background monitor'); + } + } } void _handleScanResult(BleDevice result) { @@ -359,6 +414,11 @@ class _ScannerScreenState extends State { _webServicesController.dispose(); _scanSubscription?.cancel(); + + // Remove background monitor callback + if (_supportsBackgroundMonitor) { + BleMonitorManager.instance.removeDataCallback(_onBackgroundMonitorData); + } super.dispose(); } @@ -448,6 +508,24 @@ class _ScannerScreenState extends State { ), ), actions: [ + // Background monitor toggle (Android only) + if (_supportsBackgroundMonitor) + Tooltip( + message: _isMonitorRunning + ? 'Stop background monitor' + : 'Start background monitor', + child: IconButton( + onPressed: _toggleBackgroundMonitor, + icon: Icon( + _isMonitorRunning + ? Icons.monitor_heart + : Icons.monitor_heart_outlined, + color: _isMonitorRunning + ? colorScheme.primary + : colorScheme.onSurface, + ), + ), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: FilledButton.icon( diff --git a/example/lib/home/widgets/scanned_item_widget.dart b/example/lib/home/widgets/scanned_item_widget.dart index b481acf2..f6b7f7a5 100644 --- a/example/lib/home/widgets/scanned_item_widget.dart +++ b/example/lib/home/widgets/scanned_item_widget.dart @@ -1,30 +1,93 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:universal_ble/universal_ble.dart'; import 'package:universal_ble_example/data/company_identifier_service.dart'; +import 'package:universal_ble_example/data/storage_service.dart'; import 'package:universal_ble_example/home/widgets/rssi_signal_indicator.dart'; import 'package:universal_ble_example/widgets/company_info_widget.dart'; -class ScannedItemWidget extends StatelessWidget { +class ScannedItemWidget extends StatefulWidget { final BleDevice bleDevice; final VoidCallback? onTap; final bool isExpanded; final Function(bool) onExpand; + final VoidCallback? onMonitorChanged; + const ScannedItemWidget({ super.key, required this.bleDevice, this.onTap, required this.isExpanded, required this.onExpand, + this.onMonitorChanged, }); + @override + State createState() => _ScannedItemWidgetState(); +} + +class _ScannedItemWidgetState extends State { + bool get _supportsBackgroundMonitor => !kIsWeb && Platform.isAndroid; + bool _isMonitored = false; + + @override + void initState() { + super.initState(); + if (_supportsBackgroundMonitor) { + _isMonitored = + StorageService.instance.isDeviceMonitored(widget.bleDevice.deviceId); + } + } + + @override + void didUpdateWidget(ScannedItemWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (_supportsBackgroundMonitor && + oldWidget.bleDevice.deviceId != widget.bleDevice.deviceId) { + _isMonitored = + StorageService.instance.isDeviceMonitored(widget.bleDevice.deviceId); + } + } + + Future _toggleMonitor() async { + final deviceId = widget.bleDevice.deviceId; + if (_isMonitored) { + await StorageService.instance.removeMonitoredDevice(deviceId); + setState(() => _isMonitored = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Device removed from background monitor'), + duration: Duration(seconds: 2), + ), + ); + } + } else { + await StorageService.instance.addMonitoredDevice(deviceId); + setState(() => _isMonitored = true); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Device added to background monitor'), + duration: Duration(seconds: 2), + ), + ); + } + } + widget.onMonitorChanged?.call(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - String? name = bleDevice.name; - List rawManufacturerData = bleDevice.manufacturerDataList; + String? name = widget.bleDevice.name; + List rawManufacturerData = + widget.bleDevice.manufacturerDataList; if (name == null || name.isEmpty) name = 'Unknown Device'; return Card( @@ -32,9 +95,13 @@ class ScannedItemWidget extends StatelessWidget { margin: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), + side: _isMonitored + ? BorderSide(color: colorScheme.primary, width: 2) + : BorderSide.none, ), child: InkWell( - onTap: onTap, + onTap: widget.onTap, + onLongPress: _supportsBackgroundMonitor ? _toggleMonitor : null, borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.all(16), @@ -52,7 +119,8 @@ class ScannedItemWidget extends StatelessWidget { colorScheme.primaryContainer.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(12), ), - child: RssiSignalIndicator(rssi: bleDevice.rssi ?? 0), + child: + RssiSignalIndicator(rssi: widget.bleDevice.rssi ?? 0), ), const SizedBox(width: 16), // Device info @@ -75,9 +143,32 @@ class ScannedItemWidget extends StatelessWidget { ), ), const SizedBox(width: 8), + // Monitor button (Android only) + if (_supportsBackgroundMonitor) + IconButton( + icon: Icon( + _isMonitored + ? Icons.monitor_heart + : Icons.monitor_heart_outlined, + color: _isMonitored + ? colorScheme.primary + : colorScheme.onSurface + .withValues(alpha: 0.5), + size: 20, + ), + onPressed: _toggleMonitor, + tooltip: _isMonitored + ? 'Remove from monitor' + : 'Add to monitor', + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + ), // Expand/Collapse button if (rawManufacturerData.isNotEmpty || - bleDevice.services.isNotEmpty) + widget.bleDevice.services.isNotEmpty) Container( padding: const EdgeInsets.symmetric( horizontal: 4, @@ -90,16 +181,17 @@ class ScannedItemWidget extends StatelessWidget { ), child: IconButton( icon: Icon( - isExpanded + widget.isExpanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.primary, size: 18, ), onPressed: () { - onExpand(!isExpanded); + widget.onExpand(!widget.isExpanded); }, - tooltip: isExpanded ? 'Collapse' : 'Expand', + tooltip: + widget.isExpanded ? 'Collapse' : 'Expand', padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 20, @@ -121,7 +213,7 @@ class ScannedItemWidget extends StatelessWidget { const SizedBox(width: 4), Expanded( child: Text( - bleDevice.deviceId, + widget.bleDevice.deviceId, style: TextStyle( fontSize: 12, color: colorScheme.onSurface @@ -145,26 +237,26 @@ class ScannedItemWidget extends StatelessWidget { vertical: 4, ), decoration: BoxDecoration( - color: bleDevice.paired == true + color: widget.bleDevice.paired == true ? Colors.green.withValues(alpha: 0.2) : Colors.orange.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8), ), child: Text( - bleDevice.paired == true + widget.bleDevice.paired == true ? 'Paired' : 'Unpaired', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: bleDevice.paired == true + color: widget.bleDevice.paired == true ? Colors.green.shade700 : Colors.orange.shade700, ), ), ), // Manufacturer data (only in collapsed mode) - if (!isExpanded) ...[ + if (!widget.isExpanded) ...[ ...rawManufacturerData.take(2).map((data) { final companyName = CompanyIdentifierService .instance @@ -214,8 +306,10 @@ class ScannedItemWidget extends StatelessWidget { }), ], // Services (only in collapsed mode) - if (!isExpanded) ...[ - ...bleDevice.services.take(3).map((service) { + if (!widget.isExpanded) ...[ + ...widget.bleDevice.services + .take(3) + .map((service) { return Container( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -237,9 +331,9 @@ class ScannedItemWidget extends StatelessWidget { ), ); }), - if (bleDevice.services.length > 3) + if (widget.bleDevice.services.length > 3) Text( - '+${bleDevice.services.length - 3} more', + '+${widget.bleDevice.services.length - 3} more', style: TextStyle( fontSize: 10, color: colorScheme.onSurface @@ -255,7 +349,7 @@ class ScannedItemWidget extends StatelessWidget { ], ), // Expanded details - if (isExpanded) ...[ + if (widget.isExpanded) ...[ const SizedBox(height: 16), const Divider(), const SizedBox(height: 12), @@ -395,7 +489,7 @@ class ScannedItemWidget extends StatelessWidget { }), const SizedBox(height: 12), ], - if (bleDevice.services.isNotEmpty) ...[ + if (widget.bleDevice.services.isNotEmpty) ...[ Text( 'Advertised Services', style: TextStyle( @@ -408,7 +502,7 @@ class ScannedItemWidget extends StatelessWidget { Wrap( spacing: 4, runSpacing: 4, - children: bleDevice.services.map((service) { + children: widget.bleDevice.services.map((service) { return Container( padding: const EdgeInsets.symmetric( horizontal: 8, diff --git a/example/lib/main.dart b/example/lib/main.dart index 6d8e2192..8d31af5a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:universal_ble_example/universal_ble_app.dart'; void main() async { + // Initialize port for communication between TaskHandler and UI + FlutterForegroundTask.initCommunicationPort(); + bool hasPermission = await initializeApp(); runApp(UniversalBleApp(hasPermission: hasPermission)); } diff --git a/example/pubspec.lock b/example/pubspec.lock index 203f3771..6c6bc06b 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -179,6 +179,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_foreground_task: + dependency: "direct main" + description: + name: flutter_foreground_task + sha256: "48ea45056155a99fb30b15f14f4039a044d925bc85f381ed0b2d3b00a60b99de" + url: "https://pub.dev" + source: hosted + version: "9.2.0" flutter_launcher_icons: dependency: "direct dev" description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 87f97bab..3210617a 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: path: ../ device_preview: ^1.3.1 device_preview_screenshot: ^1.0.0 + flutter_foreground_task: ^9.2.0 dev_dependencies: integration_test: From e75c4d109b42af706960d1a63cd14bd0a2d47d26 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Mon, 2 Feb 2026 17:21:44 +0100 Subject: [PATCH 4/4] Fix early return in permission request flow in UniversalBlePlugin --- .../main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt | 1 + 1 file changed, 1 insertion(+) 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 8e859421..9ad1a11b 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -116,6 +116,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), ) ) ) + return } permissionHandler?.requestPermissions(withAndroidFineLocation, callback) }