diff --git a/CHANGELOG.md b/CHANGELOG.md index a2dfa16..6acaf5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 1.0.0 * Unified error codes for all platforms * Add `isScanning` api +* Add `requestPermissions` api and auto ask permission on `startScan` ## 0.21.1 * Fix device name resolution on Windows diff --git a/README.md b/README.md index 7a7395c..86688b6 100644 --- a/README.md +++ b/README.md @@ -19,26 +19,28 @@ A cross-platform (Android/iOS/macOS/Windows/Linux/Web) Bluetooth Low Energy (BLE - [Timeout](#timeout) - [Error Handling](#error-handling) - [UUID Format Agnostic](#uuid-format-agnostic) +- [Permissions](#permissions) ## API Support -| | Android | iOS | macOS | Windows | Linux | Web | -| :------------------- | :-----: | :-: | :---: | :-----: | :----------: | :-: | -| startScan/stopScan | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| connect/disconnect | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | -| discoverServices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| read | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| write | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| pair | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | -| unpair | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | -| isPaired | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| onPairingStateChange | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | -| getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | -| enable/disable Bluetooth | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | -| onAvailabilityChange | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| requestMtu | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | +| | Android | iOS | macOS | Windows | Linux | Web | +| :---------------------------- | :-----: | :-: | :---: | :-----: | :---: | :-: | +| startScan/stopScan | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| connect/disconnect | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | +| discoverServices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| read | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| write | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| subscriptions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| pair | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ⏺ | +| unpair | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | +| isPaired | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| onPairingStateChange | ✔️ | ⏺ | ⏺ | ✔️ | ✔️ | ⏺ | +| getBluetoothAvailabilityState | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | +| enable/disable Bluetooth | ✔️ | ❌ | ❌ | ✔️ | ✔️ | ❌ | +| onAvailabilityChange | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | +| requestMtu | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | +| requestPermissions | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ## Getting Started @@ -55,8 +57,12 @@ and import it wherever you want to use it: import 'package:universal_ble/universal_ble.dart'; ``` +> **Important**: Before using BLE features, make sure to check the [Permissions](#permissions) section to see what setup is needed for your target platform (Android, iOS, macOS, Windows, Linux, or Web). + ### Scanning +The very first thing you need to do before being able to connect to a device is to discover it by calling `startScan();` + ```dart // Get scan updates from stream UniversalBle.scanStream.listen((BleDevice bleDevice) { @@ -128,7 +134,7 @@ You can optionally set a filter when scanning. A filter can have multiple condit ##### With Services -When setting this parameter, the scan results will only include devices that advertise any of the specified services. +When setting this parameter, the scan results will only include devices that advertise any of the specified services. ```dart List withServices; @@ -213,7 +219,7 @@ BleConnectionState connectionState = await bleDevice.connectionState; ### Discovering Services -After establishing a connection, services need to be discovered. This method will discover all services and their characteristics. +After establishing a connection, services need to be discovered. This method will discover all services and their characteristics. If you don't call this method then it will be automatically called when you try to get any service or characteristic. @@ -320,15 +326,17 @@ await bleDevice.pair(); ``` ##### Pair on Apple and web + For Apple and Web, pairing support depends on the device. Pairing is triggered automatically by the OS when you try to read/write from/to an encrypted characteristic. -Calling `bleDevice.pair()` will only trigger pairing if the device has an *encrypted read characteristic*. +Calling `bleDevice.pair()` will only trigger pairing if the device has an _encrypted read characteristic_. If your device only has encrypted write characteristics or you happen to know which encrypted read characteristic you want to use, you can pass it with a `pairingCommand`. ```dart await bleDevice.pair(pairingCommand: BleCommand(service:"SERVICE", characteristic:"ENCRYPTED_CHARACTERISTIC")); ``` + After pairing you can check the pairing status. #### Pairing status @@ -349,6 +357,7 @@ bool? isPaired = await bleDevice.isPaired(pairingCommand: BleCommand(service:"SE ``` ##### Discovering encrypted characteristic + To discover encrypted characteristics, make sure your device is not paired and use the example app to read/write to all discovered characteristics one by one. If one of them triggers pairing, that means it is encrypted and you can use it to construct `BleCommand(service:"SERVICE", characteristic:"ENCRYPTED_CHARACTERISTIC")`. #### Pairing state changes @@ -361,6 +370,7 @@ bleDevice.pairingStateStream.listen((bool paired) { ``` #### Unpair + ```dart bleDevice.unpair(); ``` @@ -411,7 +421,7 @@ When developing cross-platform BLE applications and devices: #### Resetting State on Hot Restart -During Flutter hot restart in debug mode, the app state is reset but native Bluetooth connections and scan operations may persist. This can lead to connection issues or stale state. +During Flutter hot restart in debug mode, the app state is reset but native Bluetooth connections and scan operations may persist. This can lead to connection issues or stale state.
Use the following helper function to properly clean up BLE state before your app restarts. @@ -468,7 +478,7 @@ Future resetBleState() async { } ``` -
+ ## Command Queue @@ -500,6 +510,7 @@ UniversalBle.onQueueUpdate = (String id, int remainingItems) { ``` To clear the queue: + ```dart /// Use [BleCommandQueue.globalQueueId] to clear the global queue. /// To clear the queue of a specific device, use `deviceId` as [id]. @@ -535,6 +546,7 @@ Universal BLE provides a unified and type-safe error handling system across all ### Error Codes All errors are categorized using the `UniversalBleErrorCode` enum, which includes codes for: + - Connection errors (timeout, failed, rejected, etc.) - Pairing errors (failed, cancelled, not allowed, etc.) - Operation errors (not supported, timeout, cancelled, etc.) @@ -601,10 +613,14 @@ BleUuidParser.number(0x180A); // "0000180a-0000-1000-8000-00805f9b34fb" BleUuidParser.compare("180a","0000180A-0000-1000-8000-00805F9B34FB"); // true ``` -## Platform-specific Setup +## Permissions + +You need to perform the following setups: ### Android +#### Manifest Permissions + Add the following permissions to your AndroidManifest.xml file: ```xml @@ -623,9 +639,16 @@ If your app uses iBeacons or BLUETOOTH_SCAN to determine location, change the la ``` -You need to programmatically request permissions on runtime. You could use a package such as [permission_handler](https://pub.dev/packages/permission_handler). -For Android 12+, request `Permission.bluetoothScan` and `Permission.bluetoothConnect`. -For Android 11 and below, request `Permission.location`. +#### Android Location Permission + +The `withAndroidFineLocation` parameter in `requestPermissions()` controls location permission requests on Android: + +- **Android 12+ (API 31+)**: + - `withAndroidFineLocation: true` → Requests `ACCESS_FINE_LOCATION` permission + - `withAndroidFineLocation: false` → Only requests Bluetooth permissions (no location permission) +- **Android 11 and below**: + - Location permission is always requested if declared in your manifest (required for BLE scanning) + - The `withAndroidFineLocation` parameter is ignored ### iOS / macOS @@ -633,13 +656,20 @@ Add `NSBluetoothPeripheralUsageDescription` and `NSBluetoothAlwaysUsageDescripti Add the `Bluetooth` capability to the macOS app from Xcode. -### Windows / Linux +**Permissions are automatically requested when calling `startScan()`.** You can also manually call `requestPermissions()` if needed. + +### Windows Your Bluetooth adapter needs to support at least Bluetooth 4.0. If you have more than 1 adapters, the first one returned from the system will be picked. When publishing on Windows, you need to declare the following [capabilities](https://learn.microsoft.com/en-us/windows/uwp/packaging/app-capability-declarations): `bluetooth, radios`. +### Linux + +Your Bluetooth adapter needs to support at least Bluetooth 4.0. If you have more than 1 adapters, the first one returned from the system will be picked. + When publishing on Linux as a snap, you need to declare the `bluez` plug in `snapcraft.yaml`. + ``` ... plugs: @@ -668,6 +698,42 @@ UniversalBle.startScan( ) ``` +**No runtime permissions are required.** The `requestPermissions()` method always succeeds on Web. + +### Manually Requesting Permissions + +**Calling `requestPermissions()` is optional.** Permissions are automatically requested when calling `startScan()`. However, you can manually call `requestPermissions()` if you want to: + +- Request permissions before scanning (e.g., to handle permission errors separately) +- Ensure permissions are granted before other operations like `connect()`, `read()`, `write()`, etc., which don't automatically request permissions + +The `requestPermissions()` method: + +- Returns successfully if all permissions are already granted or accepted by the user +- Throws a `UniversalBleException` if permissions are denied by the user +- Always succeeds on `Windows`, `Linux`, and `Web` (no runtime permissions required) + +```dart +// Optional: Manually request permissions +UniversalBle.requestPermissions( + withAndroidFineLocation: false, +); +``` + +> **Note**: When calling `startScan()`, permissions are automatically requested. To configure location permission requests during scanning, use the `platformConfig` parameter: + +```dart +UniversalBle.startScan( + platformConfig: PlatformConfig( + android: AndroidOptions( + requestLocationPermission: false, + ), + ), +); +``` + +**No runtime permissions are required.** The `requestPermissions()` method always succeeds on Windows and Linux platforms. + ## Customizing Platform Implementation of Universal Ble ```dart @@ -688,7 +754,8 @@ For more granular control, you can use the [Low-Level API](README.low_level.md). Here are some of the apps leveraging the power of `universal_ble` in production: | BT Cam Icon | [**BT Cam**](https://btcam.app)
A Bluetooth remote app for DSLR and mirrorless cameras. Compatible with Canon, Nikon, Sony, Fujifilm, GoPro, Olympus, Panasonic, Pentax, and Blackmagic. Built using Universal BLE to connect and control cameras across iOS, Android, macOS, Windows, Linux & Web. | -|:--:|:--| +| :---------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + > 💡 **Built something cool with Universal BLE?** > We'd love to showcase your app here! -> Open a pull request and add it to this section. Please include your app icon in svg! \ No newline at end of file +> Open a pull request and add it to this section. Please include your app icon in svg! diff --git a/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt b/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt new file mode 100644 index 0000000..f5d92ef --- /dev/null +++ b/android/src/main/kotlin/com/navideck/universal_ble/PermissionHandler.kt @@ -0,0 +1,249 @@ +package com.navideck.universal_ble + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.ActivityCompat +import android.Manifest +import android.annotation.SuppressLint + +private const val TAG = "PermissionHandler" + +/** + * Handles Bluetooth-related permission requests for Android. + * Automatically determines which permissions to request based on: + * 1. Android version (SDK level) + * 2. Permissions declared in AndroidManifest.xml + */ +class PermissionHandler( + private val context: Context, + private val activity: Activity, + private val requestCode: Int, +) { + private var permissionRequestCallback: ((Result) -> Unit)? = null + + /** + * Requests the required Bluetooth permissions based on the manifest and Android version. + * + * @param callback Called with the result of the permission request + */ + fun requestPermissions( + withFineLocation: Boolean, + callback: (Result) -> Unit, + ) { + // Validate required permissions are declared in manifest + val validationError = validateRequiredPermissions(withFineLocation) + if (validationError != null) { + callback(Result.failure(validationError)) + return + } + + // Check which permissions are declared in manifest + val permissionsToRequest = getRequiredPermissions(withFineLocation) + + if (permissionsToRequest.isEmpty()) { + // All required permissions are already granted + callback(Result.success(Unit)) + return + } + + // Check if we already have a pending permission request + if (permissionRequestCallback != null) { + callback( + Result.failure( + createFlutterError( + UniversalBleErrorCode.OPERATION_IN_PROGRESS, + "Permission request already in progress" + ) + ) + ) + return + } + + permissionRequestCallback = callback + ActivityCompat.requestPermissions( + activity, + permissionsToRequest.toTypedArray(), + requestCode + ) + } + + /** + * Handles permission request results. + * Should be called from onRequestPermissionsResult in the Activity. + * + * @param requestCode The request code from the permission request + * @param permissions The permissions that were requested + * @param grantResults The grant results for each permission + * @return true if the request code matches and the result was handled, false otherwise + */ + fun handlePermissionResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ): Boolean { + if (requestCode != this.requestCode) { + return false + } + + val callback = permissionRequestCallback ?: return false + permissionRequestCallback = null + + val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED } + if (allGranted) { + callback(Result.success(Unit)) + } else { + val deniedPermissions = permissions.filterIndexed { index, _ -> + grantResults[index] != PackageManager.PERMISSION_GRANTED + } + callback( + Result.failure( + createFlutterError( + UniversalBleErrorCode.FAILED, + "Permissions denied: ${deniedPermissions.joinToString(", ")}" + ) + ) + ) + } + return true + } + + + /** + * Determines which permissions need to be requested based on: + * 1. Android version + * 2. Permissions declared in AndroidManifest.xml + * 3. Whether user wants to request location permission (withFineLocation parameter) + * + * @param withFineLocation If true, request location permission when needed. + * On Android 11 and below, location is always requested if declared + * (it's mandatory for BLE scanning). + * + * Returns a list of permissions that need to be requested (excluding already granted ones) + */ + private fun getRequiredPermissions(withFineLocation: Boolean): List { + val permissionsToRequest = mutableListOf() + // Android 12+ (API 31+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // BLUETOOTH_SCAN is mandatory + if (!hasPermissionGranted(Manifest.permission.BLUETOOTH_SCAN)) { + permissionsToRequest.add(Manifest.permission.BLUETOOTH_SCAN) + } + // BLUETOOTH_CONNECT is mandatory + if (!hasPermissionGranted(Manifest.permission.BLUETOOTH_CONNECT)) { + permissionsToRequest.add(Manifest.permission.BLUETOOTH_CONNECT) + } + // Location permission is optional - only request if user wants it + if (withFineLocation) { + // Prefer ACCESS_FINE_LOCATION over ACCESS_COARSE_LOCATION + permissionsToRequest.addAll(getLocationPermissionsToAsk()) + } + } else { + // Android 11 and below + if (!hasPermissionGranted(Manifest.permission.BLUETOOTH)) { + permissionsToRequest.add(Manifest.permission.BLUETOOTH) + } + // Location permission is MANDATORY + // Prefer ACCESS_FINE_LOCATION over ACCESS_COARSE_LOCATION + permissionsToRequest.addAll(getLocationPermissionsToAsk()) + } + return permissionsToRequest + } + + private fun getLocationPermissionsToAsk(): List { + val permissionsToRequest = mutableListOf() + if (hasPermissionInManifest(Manifest.permission.ACCESS_FINE_LOCATION)) { + if (!hasPermissionGranted(Manifest.permission.ACCESS_FINE_LOCATION)) { + permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION) + } + } else if (hasPermissionInManifest(Manifest.permission.ACCESS_COARSE_LOCATION)) { + if (!hasPermissionGranted(Manifest.permission.ACCESS_COARSE_LOCATION)) { + permissionsToRequest.add(Manifest.permission.ACCESS_COARSE_LOCATION) + } + } + return permissionsToRequest + } + + /** + * Checks if a permission is declared in AndroidManifest.xml + */ + private fun hasPermissionInManifest(permission: String): Boolean { + return try { + val packageInfo = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_PERMISSIONS + ) + packageInfo.requestedPermissions?.contains(permission) == true + } catch (e: Exception) { + Log.e(TAG, "Error checking permission in manifest: ${e.message}") + false + } + } + + private fun hasPermissionGranted(permission: String): Boolean { + return ActivityCompat.checkSelfPermission( + context, + permission + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * Validates that all required permissions are declared in AndroidManifest.xml. + * Returns an error if any required permission is missing. + * + * @param withFineLocation Whether location permission should be requested + * @return FlutterError if validation fails, null if all required permissions are declared + */ + private fun validateRequiredPermissions(withFineLocation: Boolean): FlutterError? { + val sdkInt = Build.VERSION.SDK_INT + val missingPermissions = mutableListOf() + + val hasDeclaredFineLocation = + hasPermissionInManifest(Manifest.permission.ACCESS_FINE_LOCATION) + val hasDeclaredCoarseLocation = + hasPermissionInManifest(Manifest.permission.ACCESS_COARSE_LOCATION) + val hasDeclaredLocationPermission = hasDeclaredFineLocation || hasDeclaredCoarseLocation + + // Android 12+ (API 31+) + @SuppressLint("InlinedApi") + if (sdkInt >= Build.VERSION_CODES.S) { + // BLUETOOTH_SCAN is mandatory on Android 12+ + if (!hasPermissionInManifest(Manifest.permission.BLUETOOTH_SCAN)) { + missingPermissions.add(Manifest.permission.BLUETOOTH_SCAN) + } + + // BLUETOOTH_CONNECT is mandatory on Android 12+ + if (!hasPermissionInManifest(Manifest.permission.BLUETOOTH_CONNECT)) { + missingPermissions.add(Manifest.permission.BLUETOOTH_CONNECT) + } + + // Location permission is optional on Android 12+ (depends on neverForLocation and withFineLocation) + // Only validate if it's actually needed + if (withFineLocation && !hasDeclaredLocationPermission) { + missingPermissions.add("${Manifest.permission.ACCESS_FINE_LOCATION} or ${Manifest.permission.ACCESS_COARSE_LOCATION}") + } + } else { + // Android 11 and below + if (!hasPermissionInManifest(Manifest.permission.BLUETOOTH)) { + missingPermissions.add(Manifest.permission.BLUETOOTH) + } + // Android 11 and below, Location permission is MANDATORY for BLE scanning + if (!hasDeclaredLocationPermission) { + missingPermissions.add("${Manifest.permission.ACCESS_FINE_LOCATION} or ${Manifest.permission.ACCESS_COARSE_LOCATION}") + } + } + + if (missingPermissions.isNotEmpty()) { + return createFlutterError( + UniversalBleErrorCode.FAILED, + "Required permissions are not declared in AndroidManifest.xml", + "Missing permissions: ${missingPermissions.joinToString(", ")}. " + + "Please add these permissions to your AndroidManifest.xml file. " + + "See README.md for more information." + ) + } + return null + } +} diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt index 0b5da0b..21108fe 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBle.g.kt @@ -443,6 +443,7 @@ private open class UniversalBlePigeonCodec : StandardMessageCodec() { */ interface UniversalBlePlatformChannel { fun getBluetoothAvailabilityState(callback: (Result) -> Unit) + fun requestPermissions(withAndroidFineLocation: Boolean, callback: (Result) -> Unit) fun enableBluetooth(callback: (Result) -> Unit) fun disableBluetooth(callback: (Result) -> Unit) fun startScan(filter: UniversalScanFilter?) @@ -488,6 +489,25 @@ interface UniversalBlePlatformChannel { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val withAndroidFineLocationArg = args[0] as Boolean + api.requestPermissions(withAndroidFineLocationArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(UniversalBlePigeonUtils.wrapError(error)) + } else { + reply.reply(UniversalBlePigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.enableBluetooth$separatedMessageChannelSuffix", codec) if (api != null) { 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 2666bba..52b531c 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -39,9 +39,12 @@ private const val TAG = "UniversalBlePlugin" @SuppressLint("MissingPermission") class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), FlutterPlugin, - ActivityAware, PluginRegistry.ActivityResultListener { + ActivityAware, PluginRegistry.ActivityResultListener, + PluginRegistry.RequestPermissionsResultListener { private val bluetoothEnableRequestCode = 2342313 private val bluetoothDisableRequestCode = 2342414 + private val permissionRequestCode = 2342515 + private var permissionHandler: PermissionHandler? = null private var callbackChannel: UniversalBleCallbackChannel? = null private var mainThreadHandler: Handler? = null private lateinit var context: Context @@ -95,6 +98,20 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), ) } + override fun requestPermissions(withAndroidFineLocation: Boolean, callback: (Result) -> Unit) { + if (permissionHandler == null) { + callback( + Result.failure( + createFlutterError( + UniversalBleErrorCode.FAILED, + "PermissionHandler is not initialized" + ) + ) + ) + } + permissionHandler?.requestPermissions(withAndroidFineLocation, callback) + } + override fun enableBluetooth(callback: (Result) -> Unit) { if (bluetoothManager.adapter.isEnabled) { callback(Result.success(true)) @@ -188,7 +205,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), } override fun isScanning(): Boolean { - return safeScanner.isScanning() + return safeScanner.isScanning() } override fun connect(deviceId: String) { @@ -1130,13 +1147,28 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), override fun onAttachedToActivity(binding: ActivityPluginBinding) { activity = binding.activity + permissionHandler = PermissionHandler(context, binding.activity, permissionRequestCode) binding.addActivityResultListener(this) + binding.addRequestPermissionsResultListener(this) } override fun onDetachedFromActivity() { activity = null + permissionHandler = null } override fun onDetachedFromActivityForConfigChanges() {} - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {} + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + permissionHandler = PermissionHandler(context, binding.activity, permissionRequestCode) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ): Boolean { + return permissionHandler?.handlePermissionResult(requestCode, permissions, grantResults) + ?: false + } } \ No newline at end of file diff --git a/darwin/Classes/UniversalBle.g.swift b/darwin/Classes/UniversalBle.g.swift index eca68be..9ebef10 100644 --- a/darwin/Classes/UniversalBle.g.swift +++ b/darwin/Classes/UniversalBle.g.swift @@ -473,6 +473,7 @@ class UniversalBlePigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol UniversalBlePlatformChannel { func getBluetoothAvailabilityState(completion: @escaping (Result) -> Void) + func requestPermissions(withAndroidFineLocation: Bool, completion: @escaping (Result) -> Void) func enableBluetooth(completion: @escaping (Result) -> Void) func disableBluetooth(completion: @escaping (Result) -> Void) func startScan(filter: UniversalScanFilter?) throws @@ -513,6 +514,23 @@ class UniversalBlePlatformChannelSetup { } else { getBluetoothAvailabilityStateChannel.setMessageHandler(nil) } + let requestPermissionsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + requestPermissionsChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let withAndroidFineLocationArg = args[0] as! Bool + api.requestPermissions(withAndroidFineLocation: withAndroidFineLocationArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + requestPermissionsChannel.setMessageHandler(nil) + } let enableBluetoothChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.enableBluetooth\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { enableBluetoothChannel.setMessageHandler { _, reply in diff --git a/darwin/Classes/UniversalBlePlugin.swift b/darwin/Classes/UniversalBlePlugin.swift index 1b09de4..cb2b246 100644 --- a/darwin/Classes/UniversalBlePlugin.swift +++ b/darwin/Classes/UniversalBlePlugin.swift @@ -33,6 +33,7 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral private var universalBleFilterUtil = UniversalBleFilterUtil() private lazy var manager: CBCentralManager = .init(delegate: self, queue: nil) private var availabilityStateUpdateHandlers: [(Result) -> Void] = [] + private var requestPermissionStateUpdateHandlers: [(Result) -> Void] = [] private var discoveredServicesProgressMap: [String: [UniversalBleService]] = [:] private var characteristicReadFutures = [CharacteristicReadFuture]() private var characteristicWriteFutures = [CharacteristicWriteFuture]() @@ -55,6 +56,27 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral } } + func requestPermissions(withAndroidFineLocation _: Bool, completion: @escaping (Result) -> Void) { + if manager.state != .unknown { + completePermissionRequest(completion: completion) + } else { + requestPermissionStateUpdateHandlers.append(completion) + _ = manager + } + } + + func completePermissionRequest(completion: @escaping (Result) -> Void) { + let state = manager.state + switch state { + case .unauthorized: + completion(.failure(createFlutterError(code: .bluetoothUnauthorized, message: "Not authorized to access Bluetooth"))) + case .unsupported: + completion(.failure(createFlutterError(code: .notSupported, message: "Bluetooth is not supported"))) + default: + completion(.success(())) + } + } + func enableBluetooth(completion: @escaping (Result) -> Void) { completion(Result.failure(createFlutterError(code: .notSupported))) } @@ -347,6 +369,11 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral handler(.success(state)) return true } + // Complete Pending permission request handler + requestPermissionStateUpdateHandlers.removeAll { handler in + completePermissionRequest(completion: handler) + return true + } } public func centralManager(_: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index de66133..500663a 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -24,7 +24,7 @@ if (flutterVersionName == null) { android { namespace "com.navideck.universal_ble_example" - compileSdkVersion 35 + compileSdkVersion 36 ndkVersion flutter.ndkVersion compileOptions { @@ -45,7 +45,7 @@ android { // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion flutter.minSdkVersion - targetSdkVersion 35 + targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 725af88..191b9de 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 6f8524c..4a756d6 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https://services.gradle.org/distributions/gradle-8.14.3-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 0129fb6..3080135 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.6.1" apply false - id "org.jetbrains.kotlin.android" version "1.9.0" apply false + id "com.android.application" version "8.13.1" apply false + id "org.jetbrains.kotlin.android" version "2.2.21" apply false } include ":app" \ No newline at end of file diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index b349027..200504d 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,39 +1,27 @@ PODS: - - device_info_plus (0.0.1): - - Flutter - Flutter (1.0.0) - integration_test (0.0.1): - Flutter - - permission_handler_apple (9.3.0): - - Flutter - universal_ble (0.0.1): - Flutter - FlutterMacOS DEPENDENCIES: - - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - universal_ble (from `.symlinks/plugins/universal_ble/darwin`) EXTERNAL SOURCES: - device_info_plus: - :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter integration_test: :path: ".symlinks/plugins/integration_test/ios" - permission_handler_apple: - :path: ".symlinks/plugins/permission_handler_apple/ios" universal_ble: :path: ".symlinks/plugins/universal_ble/darwin" SPEC CHECKSUMS: - device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 - permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6 PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index beb7003..5d7782a 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -198,7 +198,6 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 2F5EDC3CC74E7431E4CB95DF /* [CP] Embed Pods Frameworks */, - 6B74A273405B2F59259FD41D /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -325,23 +324,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 6B74A273405B2F59259FD41D /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 7E79F47357B64513A488AC3E /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/example/lib/data/mock_universal_ble.dart b/example/lib/data/mock_universal_ble.dart index dd00613..1676147 100644 --- a/example/lib/data/mock_universal_ble.dart +++ b/example/lib/data/mock_universal_ble.dart @@ -131,4 +131,9 @@ class MockUniversalBle extends UniversalBlePlatform { Future disableBluetooth() { throw UnimplementedError(); } + + @override + Future requestPermissions({bool withAndroidFineLocation = false}) { + throw UnimplementedError(); + } } diff --git a/example/lib/data/permission_handler.dart b/example/lib/data/permission_handler.dart deleted file mode 100644 index c98d056..0000000 --- a/example/lib/data/permission_handler.dart +++ /dev/null @@ -1,101 +0,0 @@ -// ignore_for_file: avoid_print - -import 'dart:async'; -import 'dart:io'; - -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter/foundation.dart'; -import 'package:permission_handler/permission_handler.dart'; - -/* - Required Permissions : - <-----------> - IOS : - - Bluetooth - <-----------> - Android : - if AndroidVersions < 12 - - Location - - Bluetooth - else - - Bluetooth Scan - - Bluetooth Connect - <-----------> - Macos : - <-----------> - Windows : None - <-----------> - Linux : None - <-----------> - Web : - Check if Browser Supports Bluetooth - */ -class PermissionHandler { - static Future arePermissionsGranted() async { - if (!isMobilePlatform) return true; - - var status = await _permissionStatus; - bool blePermissionGranted = status[0]; - bool locationPermissionGranted = status[1]; - - if (locationPermissionGranted && blePermissionGranted) return true; - - if (!blePermissionGranted) { - PermissionStatus blePermissionCheck = - await Permission.bluetooth.request(); - if (blePermissionCheck.isPermanentlyDenied) { - print("Bluetooth Permission Permanently Denied"); - openAppSettings(); - } - return false; - } - - if (!locationPermissionGranted) { - PermissionStatus locationPermissionCheck = - await Permission.location.request(); - if (locationPermissionCheck.isPermanentlyDenied) { - print("Location Permission Permanently Denied"); - openAppSettings(); - } - return false; - } - - return false; - } - - static Future> get _permissionStatus async { - bool blePermissionGranted = false; - bool locationPermissionGranted = false; - - if (await requiresExplicitAndroidBluetoothPermissions) { - bool bleConnectPermission = - (await Permission.bluetoothConnect.request()).isGranted; - bool bleScanPermission = - (await Permission.bluetoothScan.request()).isGranted; - - blePermissionGranted = bleConnectPermission && bleScanPermission; - locationPermissionGranted = true; - } else { - PermissionStatus permissionStatus = await Permission.bluetooth.request(); - blePermissionGranted = permissionStatus.isGranted; - locationPermissionGranted = await requiresLocationPermission - ? (await Permission.locationWhenInUse.request()).isGranted - : true; - } - return [blePermissionGranted, locationPermissionGranted]; - } - - static bool get isMobilePlatform => - !kIsWeb && (Platform.isAndroid || Platform.isIOS); - - static Future get requiresLocationPermission async => - !kIsWeb && - Platform.isAndroid && - (!await requiresExplicitAndroidBluetoothPermissions); - - static Future get requiresExplicitAndroidBluetoothPermissions async { - if (kIsWeb || !Platform.isAndroid) return false; - AndroidDeviceInfo androidInfo = await DeviceInfoPlugin().androidInfo; - return androidInfo.version.sdkInt >= 31; - } -} diff --git a/example/lib/home/home.dart b/example/lib/home/home.dart index c9f92bb..c374160 100644 --- a/example/lib/home/home.dart +++ b/example/lib/home/home.dart @@ -7,7 +7,6 @@ import 'package:universal_ble_example/data/mock_universal_ble.dart'; import 'package:universal_ble_example/home/widgets/scan_filter_widget.dart'; import 'package:universal_ble_example/home/widgets/scanned_devices_placeholder_widget.dart'; import 'package:universal_ble_example/home/widgets/scanned_item_widget.dart'; -import 'package:universal_ble_example/data/permission_handler.dart'; import 'package:universal_ble_example/peripheral_details/peripheral_detail_page.dart'; import 'package:universal_ble_example/widgets/platform_button.dart'; import 'package:universal_ble_example/widgets/responsive_buttons_grid.dart'; @@ -90,9 +89,7 @@ class _MyAppState extends State { } Future startScan() async { - await UniversalBle.startScan( - scanFilter: scanFilter, - ); + await UniversalBle.startScan(scanFilter: scanFilter); } Future _getSystemDevices() async { @@ -221,12 +218,15 @@ class _MyAppState extends State { ), if (BleCapabilities.requiresRuntimePermission) PlatformButton( - text: 'Check Permissions', + text: 'Request Permissions', onPressed: () async { - bool hasPermissions = - await PermissionHandler.arePermissionsGranted(); - if (hasPermissions) { + try { + await UniversalBle.requestPermissions( + withAndroidFineLocation: false, + ); showSnackbar("Permissions granted"); + } catch (e) { + showSnackbar(e.toString()); } }, ), diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index b02da4c..7ddba83 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,8 @@ import FlutterMacOS import Foundation -import device_info_plus import universal_ble func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) } diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index bf1d37e..6a79838 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -1,26 +1,20 @@ PODS: - - device_info_plus (0.0.1): - - FlutterMacOS - FlutterMacOS (1.0.0) - universal_ble (0.0.1): - Flutter - FlutterMacOS DEPENDENCIES: - - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`) EXTERNAL SOURCES: - device_info_plus: - :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos FlutterMacOS: :path: Flutter/ephemeral universal_ble: :path: Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin SPEC CHECKSUMS: - device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6 diff --git a/example/pubspec.lock b/example/pubspec.lock index da0e99c..97d521a 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -81,22 +81,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" - device_info_plus: - dependency: "direct main" - description: - name: device_info_plus - sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" - url: "https://pub.dev" - source: hosted - version: "9.1.2" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f - url: "https://pub.dev" - source: hosted - version: "7.0.3" expandable: dependency: "direct main" description: @@ -160,11 +144,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -179,10 +158,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: @@ -235,10 +214,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" path: dependency: transitive description: @@ -247,54 +226,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - permission_handler: - dependency: "direct main" - description: - name: permission_handler - sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" - url: "https://pub.dev" - source: hosted - version: "11.4.0" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc - url: "https://pub.dev" - source: hosted - version: "12.1.0" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 - url: "https://pub.dev" - source: hosted - version: "9.4.7" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" petitparser: dependency: transitive description: @@ -384,10 +315,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" typed_data: dependency: transitive description: @@ -402,7 +333,7 @@ packages: path: ".." relative: true source: path - version: "0.21.1" + version: "1.0.0" vector_math: dependency: transitive description: @@ -435,22 +366,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" - win32: - dependency: transitive - description: - name: win32 - sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" - url: "https://pub.dev" - source: hosted - version: "5.14.0" - win32_registry: - dependency: transitive - description: - name: win32_registry - sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" - url: "https://pub.dev" - source: hosted - version: "1.1.5" xml: dependency: transitive description: @@ -461,4 +376,4 @@ packages: version: "6.6.1" sdks: dart: ">=3.8.0 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 8b490bc..1e012a2 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -12,8 +12,6 @@ dependencies: convert: ^3.1.1 expandable: ^5.0.1 cupertino_icons: ^1.0.2 - permission_handler: ^11.3.1 - device_info_plus: ^9.0.2 universal_ble: path: ../ diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index 3860860..5c765e6 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -6,12 +6,9 @@ #include "generated_plugin_registrant.h" -#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { - PermissionHandlerWindowsPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UniversalBlePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("UniversalBlePluginCApi")); } diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 3dcad9f..97ecaa5 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - permission_handler_windows universal_ble ) diff --git a/lib/src/models/platform_config.dart b/lib/src/models/platform_config.dart index df2c823..d0d0b0c 100644 --- a/lib/src/models/platform_config.dart +++ b/lib/src/models/platform_config.dart @@ -1,8 +1,9 @@ /// Platform specific config to scan devices class PlatformConfig { WebOptions? web; + AndroidOptions? android; - PlatformConfig({this.web}); + PlatformConfig({this.web, this.android}); } /// Web options to scan devices @@ -21,3 +22,10 @@ class WebOptions { this.optionalManufacturerData = const [], }); } + +/// Android options to scan devices +class AndroidOptions { + bool requestLocationPermission; + + AndroidOptions({this.requestLocationPermission = true}); +} diff --git a/lib/src/universal_ble.dart b/lib/src/universal_ble.dart index 7ea145d..3bb8cd9 100644 --- a/lib/src/universal_ble.dart +++ b/lib/src/universal_ble.dart @@ -63,6 +63,20 @@ class UniversalBle { ); } + /// Request permissions. + /// if all permissions are already granted or granted by user, this method will succeed. + /// it will throw exception if permissions are denied by user. + /// [withAndroidFineLocation] is used to request fine location permission on Android 12+ (API 31+). + /// on Android lower than 12, this method will request location permission regardless of the [withAndroidFineLocation] value. + /// `Windows`, `Linux` and `Web` will always succeed. + static Future requestPermissions({ + bool withAndroidFineLocation = false, + }) async { + return _platform.requestPermissions( + withAndroidFineLocation: withAndroidFineLocation, + ); + } + /// Start scan. /// Scan results will arrive in [onScanResult] listener. /// It might throw errors if Bluetooth is not available. diff --git a/lib/src/universal_ble_exceptions.dart b/lib/src/universal_ble_exceptions.dart index 16c9715..194284d 100644 --- a/lib/src/universal_ble_exceptions.dart +++ b/lib/src/universal_ble_exceptions.dart @@ -15,7 +15,8 @@ class UniversalBleException implements Exception { }); @override - String toString() => message; + String toString() => + "UniversalBleException: Code: $code, Message: $message, Details: $details"; factory UniversalBleException.fromError(dynamic error) { String message = error.toString(); diff --git a/lib/src/universal_ble_linux/universal_ble_linux.dart b/lib/src/universal_ble_linux/universal_ble_linux.dart index ace1ad5..6a746ad 100644 --- a/lib/src/universal_ble_linux/universal_ble_linux.dart +++ b/lib/src/universal_ble_linux/universal_ble_linux.dart @@ -402,6 +402,12 @@ class UniversalBleLinux extends UniversalBlePlatform { .toList(); } + @override + Future requestPermissions( + {bool withAndroidFineLocation = false}) async { + // No permissions to request on linux + } + AvailabilityState get _availabilityState { return _activeAdapter?.powered == true ? AvailabilityState.poweredOn diff --git a/lib/src/universal_ble_pigeon/universal_ble.g.dart b/lib/src/universal_ble_pigeon/universal_ble.g.dart index 63847b8..4f36027 100644 --- a/lib/src/universal_ble_pigeon/universal_ble.g.dart +++ b/lib/src/universal_ble_pigeon/universal_ble.g.dart @@ -522,6 +522,32 @@ class UniversalBlePlatformChannel { } } + Future requestPermissions(bool withAndroidFineLocation) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([withAndroidFineLocation]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + Future enableBluetooth() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.enableBluetooth$pigeonVar_messageChannelSuffix'; 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 6c03e25..b513c85 100644 --- a/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart +++ b/lib/src/universal_ble_pigeon/universal_ble_pigeon_channel.dart @@ -44,7 +44,7 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform { ScanFilter? scanFilter, PlatformConfig? platformConfig, }) async { - await _ensureInitialized(); + await _ensureInitialized(platformConfig); _bleFilter.scanFilter = scanFilter; await _executeWithErrorHandling( () => _channel.startScan( @@ -142,6 +142,14 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform { Future unpair(String deviceId) => _executeWithErrorHandling(() => _channel.unPair(deviceId)); + @override + Future requestPermissions( + {bool withAndroidFineLocation = false}) async { + await _executeWithErrorHandling( + () => _channel.requestPermissions(withAndroidFineLocation), + ); + } + @override Future> getSystemDevices( List? withServices, @@ -179,26 +187,15 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform { } } - Future _ensureInitialized() async { - // Check bluetooth availability on Apple - // so that it will ask permission only when required, and throw error on failed - if (defaultTargetPlatform == TargetPlatform.iOS || + Future _ensureInitialized(PlatformConfig? platformConfig) async { + // Check bluetooth availability on Apple and Android + if (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS) { - AvailabilityState state = await getBluetoothAvailabilityState(); - switch (state) { - case AvailabilityState.unauthorized: - throw UniversalBleException( - code: UniversalBleErrorCode.bluetoothUnauthorized, - message: "Not authorized to access Bluetooth", - ); - case AvailabilityState.unsupported: - throw UniversalBleException( - code: UniversalBleErrorCode.notSupported, - message: "Bluetooth is not supported", - ); - default: - // Ignore rest.. - } + await requestPermissions( + withAndroidFineLocation: + platformConfig?.android?.requestLocationPermission ?? false, + ); } } } diff --git a/lib/src/universal_ble_platform_interface.dart b/lib/src/universal_ble_platform_interface.dart index 25d46cc..b4265da 100644 --- a/lib/src/universal_ble_platform_interface.dart +++ b/lib/src/universal_ble_platform_interface.dart @@ -36,6 +36,8 @@ abstract class UniversalBlePlatform { Future disableBluetooth(); + Future requestPermissions({bool withAndroidFineLocation = false}); + Future startScan({ ScanFilter? scanFilter, PlatformConfig? platformConfig, diff --git a/lib/src/universal_ble_web/universal_ble_web.dart b/lib/src/universal_ble_web/universal_ble_web.dart index ed3791b..15bb926 100644 --- a/lib/src/universal_ble_web/universal_ble_web.dart +++ b/lib/src/universal_ble_web/universal_ble_web.dart @@ -251,6 +251,12 @@ class UniversalBleWeb extends UniversalBlePlatform { } /// `Unimplemented` + @override + Future requestPermissions( + {bool withAndroidFineLocation = false}) async { + // No permissions to request on Web + } + @override Future requestMtu(String deviceId, int expectedMtu) { throw UniversalBleException( diff --git a/pigeon/universal_ble.dart b/pigeon/universal_ble.dart index 334ba42..f55095a 100644 --- a/pigeon/universal_ble.dart +++ b/pigeon/universal_ble.dart @@ -24,6 +24,9 @@ abstract class UniversalBlePlatformChannel { @async int getBluetoothAvailabilityState(); + @async + void requestPermissions(bool withAndroidFineLocation); + @async bool enableBluetooth(); diff --git a/test/ble_characteristic_test.dart b/test/ble_characteristic_test.dart index db189b4..951f441 100644 --- a/test/ble_characteristic_test.dart +++ b/test/ble_characteristic_test.dart @@ -124,4 +124,9 @@ class _UniversalBleMock extends UniversalBlePlatformMock { {Duration? timeout}) async { return charValue ?? Uint8List(0); } + + @override + Future requestPermissions({bool withAndroidFineLocation = false}) { + throw UnimplementedError(); + } } diff --git a/windows/src/generated/universal_ble.g.cpp b/windows/src/generated/universal_ble.g.cpp index 14b8e21..7006e13 100644 --- a/windows/src/generated/universal_ble.g.cpp +++ b/windows/src/generated/universal_ble.g.cpp @@ -534,6 +534,35 @@ void UniversalBlePlatformChannel::SetUp( channel.SetMessageHandler(nullptr); } } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_with_android_fine_location_arg = args.at(0); + if (encodable_with_android_fine_location_arg.IsNull()) { + reply(WrapError("with_android_fine_location_arg unexpectedly null.")); + return; + } + const auto& with_android_fine_location_arg = std::get(encodable_with_android_fine_location_arg); + api->RequestPermissions(with_android_fine_location_arg, [reply](std::optional&& output) { + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + }); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } { BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.enableBluetooth" + prepended_suffix, &GetCodec()); if (api != nullptr) { diff --git a/windows/src/generated/universal_ble.g.h b/windows/src/generated/universal_ble.g.h index a0cf7fd..dc5bece 100644 --- a/windows/src/generated/universal_ble.g.h +++ b/windows/src/generated/universal_ble.g.h @@ -348,6 +348,9 @@ class UniversalBlePlatformChannel { UniversalBlePlatformChannel& operator=(const UniversalBlePlatformChannel&) = delete; virtual ~UniversalBlePlatformChannel() {} virtual void GetBluetoothAvailabilityState(std::function reply)> result) = 0; + virtual void RequestPermissions( + bool with_android_fine_location, + std::function reply)> result) = 0; virtual void EnableBluetooth(std::function reply)> result) = 0; virtual void DisableBluetooth(std::function reply)> result) = 0; virtual std::optional StartScan(const UniversalScanFilter* filter) = 0; diff --git a/windows/src/universal_ble_plugin.cpp b/windows/src/universal_ble_plugin.cpp index e22bca0..fe5d5d7 100644 --- a/windows/src/universal_ble_plugin.cpp +++ b/windows/src/universal_ble_plugin.cpp @@ -118,6 +118,14 @@ void UniversalBlePlugin::DisableBluetooth( }); } +void UniversalBlePlugin::RequestPermissions( + bool with_android_fine_location, + std::function reply)> result) { + // Windows does not require runtime permissions for Bluetooth + result(std::nullopt); + return; +} + std::optional UniversalBlePlugin::StartScan(const UniversalScanFilter *filter) { diff --git a/windows/src/universal_ble_plugin.h b/windows/src/universal_ble_plugin.h index c16003c..af6dccb 100644 --- a/windows/src/universal_ble_plugin.h +++ b/windows/src/universal_ble_plugin.h @@ -161,6 +161,9 @@ namespace universal_ble ErrorOr IsScanning() override; std::optional Connect(const std::string &device_id) override; std::optional Disconnect(const std::string &device_id) override; + void RequestPermissions( + bool with_android_fine_location, + std::function reply)> result) override; void DiscoverServices( const std::string &device_id, std::function reply)> result) override;