Implement hasPermissions API (#201)

* Implement hasPermissions api

* Bump minimum MacOS version to 10.15

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Rohit Sangwan
2025-12-13 02:40:21 +05:30
committed by GitHub
parent 89abb3bf03
commit 97936046e0
23 changed files with 175 additions and 36 deletions
+3
View File
@@ -1,8 +1,11 @@
## 1.0.0 ## 1.0.0
* BREAKING CHANGE: `OnValueChange` callback also includes timestamp * BREAKING CHANGE: `OnValueChange` callback also includes timestamp
* BREAKING CHANGE: Bump minimum IOS version to 13.1
* BREAKING CHANGE: Bump minimum MacOS version to 10.15
* Fix Windows 11 crash on cancelling pairing * Fix Windows 11 crash on cancelling pairing
* Unified error codes for all platforms * Unified error codes for all platforms
* Add `isScanning` api * Add `isScanning` api
* Add `hasPermissions` api
* Add `requestPermissions` api and auto ask permission on `startScan` * Add `requestPermissions` api and auto ask permission on `startScan`
* `disconnect` now waits for disconnection confirmation before returning * `disconnect` now waits for disconnection confirmation before returning
* Improve Windows disconnection event handling and cleanup * Improve Windows disconnection event handling and cleanup
@@ -24,6 +24,18 @@ class PermissionHandler(
) { ) {
private var permissionRequestCallback: ((Result<Unit>) -> Unit)? = null private var permissionRequestCallback: ((Result<Unit>) -> Unit)? = null
/**
* Check if we have required permissions
*/
fun hasPermissions(withFineLocation: Boolean): Boolean {
val validationError = validateRequiredPermissions(withFineLocation)
if (validationError != null) {
throw validationError
}
val permissionsToRequest = getRequiredPermissions(withFineLocation)
return permissionsToRequest.isEmpty()
}
/** /**
* Requests the required Bluetooth permissions based on the manifest and Android version. * Requests the required Bluetooth permissions based on the manifest and Android version.
* *
@@ -510,6 +510,7 @@ private open class UniversalBlePigeonCodec : StandardMessageCodec() {
*/ */
interface UniversalBlePlatformChannel { interface UniversalBlePlatformChannel {
fun getBluetoothAvailabilityState(callback: (Result<Long>) -> Unit) fun getBluetoothAvailabilityState(callback: (Result<Long>) -> Unit)
fun hasPermissions(withAndroidFineLocation: Boolean): Boolean
fun requestPermissions(withAndroidFineLocation: Boolean, callback: (Result<Unit>) -> Unit) fun requestPermissions(withAndroidFineLocation: Boolean, callback: (Result<Unit>) -> Unit)
fun enableBluetooth(callback: (Result<Boolean>) -> Unit) fun enableBluetooth(callback: (Result<Boolean>) -> Unit)
fun disableBluetooth(callback: (Result<Boolean>) -> Unit) fun disableBluetooth(callback: (Result<Boolean>) -> Unit)
@@ -557,6 +558,23 @@ interface UniversalBlePlatformChannel {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.hasPermissions$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val withAndroidFineLocationArg = args[0] as Boolean
val wrapped: List<Any?> = try {
listOf(api.hasPermissions(withAndroidFineLocationArg))
} catch (exception: Throwable) {
UniversalBlePigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
@@ -95,6 +95,10 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
) )
} }
override fun hasPermissions(withAndroidFineLocation: Boolean): Boolean {
return permissionHandler?.hasPermissions(withAndroidFineLocation) ?: false
}
override fun requestPermissions( override fun requestPermissions(
withAndroidFineLocation: Boolean, withAndroidFineLocation: Boolean,
callback: (Result<Unit>) -> Unit, callback: (Result<Unit>) -> Unit,
+16
View File
@@ -529,6 +529,7 @@ class UniversalBlePigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol UniversalBlePlatformChannel { protocol UniversalBlePlatformChannel {
func getBluetoothAvailabilityState(completion: @escaping (Result<Int64, Error>) -> Void) func getBluetoothAvailabilityState(completion: @escaping (Result<Int64, Error>) -> Void)
func hasPermissions(withAndroidFineLocation: Bool) throws -> Bool
func requestPermissions(withAndroidFineLocation: Bool, completion: @escaping (Result<Void, Error>) -> Void) func requestPermissions(withAndroidFineLocation: Bool, completion: @escaping (Result<Void, Error>) -> Void)
func enableBluetooth(completion: @escaping (Result<Bool, Error>) -> Void) func enableBluetooth(completion: @escaping (Result<Bool, Error>) -> Void)
func disableBluetooth(completion: @escaping (Result<Bool, Error>) -> Void) func disableBluetooth(completion: @escaping (Result<Bool, Error>) -> Void)
@@ -571,6 +572,21 @@ class UniversalBlePlatformChannelSetup {
} else { } else {
getBluetoothAvailabilityStateChannel.setMessageHandler(nil) getBluetoothAvailabilityStateChannel.setMessageHandler(nil)
} }
let hasPermissionsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.hasPermissions\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasPermissionsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let withAndroidFineLocationArg = args[0] as! Bool
do {
let result = try api.hasPermissions(withAndroidFineLocation: withAndroidFineLocationArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
hasPermissionsChannel.setMessageHandler(nil)
}
let requestPermissionsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) let requestPermissionsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api { if let api = api {
requestPermissionsChannel.setMessageHandler { message, reply in requestPermissionsChannel.setMessageHandler { message, reply in
+5 -13
View File
@@ -56,6 +56,10 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral
} }
} }
func hasPermissions(withAndroidFineLocation _: Bool) throws -> Bool {
return CBCentralManager.authorization == .allowedAlways
}
func requestPermissions(withAndroidFineLocation _: Bool, completion: @escaping (Result<Void, any Error>) -> Void) { func requestPermissions(withAndroidFineLocation _: Bool, completion: @escaping (Result<Void, any Error>) -> Void) {
if manager.state != .unknown { if manager.state != .unknown {
completePermissionRequest(completion: completion) completePermissionRequest(completion: completion)
@@ -114,21 +118,9 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral
} }
func isScanning() throws -> Bool { func isScanning() throws -> Bool {
var hasAuthorization = true if CBCentralManager.authorization == .allowedAlways {
#if os(iOS)
if #available(iOS 13.1, *) {
hasAuthorization = CBCentralManager.authorization == .allowedAlways
} else {
return isManageScanning
}
#elseif os(macOS)
hasAuthorization = CBCentralManager.authorization == .allowedAlways
#endif
if hasAuthorization {
return manager.isScanning return manager.isScanning
} }
return isManageScanning return isManageScanning
} }
+2 -2
View File
@@ -16,8 +16,8 @@ A new Flutter plugin project.
s.source_files = 'Classes/**/*' s.source_files = 'Classes/**/*'
s.ios.dependency 'Flutter' s.ios.dependency 'Flutter'
s.osx.dependency 'FlutterMacOS' s.osx.dependency 'FlutterMacOS'
s.ios.deployment_target = '9.0' s.ios.deployment_target = '13.1'
s.osx.deployment_target = '10.12' s.osx.deployment_target = '10.15'
# Flutter.framework does not contain a i386 slice. # Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
+1 -1
View File
@@ -22,7 +22,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6 universal_ble: 65e1257dffc557cc7991a93d253beeddc7c1dc92
PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694 PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694
+15 -1
View File
@@ -216,7 +216,20 @@ class _MyAppState extends State<MyApp> {
showSnackbar("BluetoothDisabled: $isDisabled"); showSnackbar("BluetoothDisabled: $isDisabled");
}, },
), ),
if (BleCapabilities.requiresRuntimePermission) if (BleCapabilities.requiresRuntimePermission) ...[
PlatformButton(
text: 'Has Permissions',
onPressed: () async {
try {
bool hasPermissions = await UniversalBle.hasPermissions(
withAndroidFineLocation: false,
);
showSnackbar("Has Permissions: $hasPermissions");
} catch (e) {
showSnackbar(e.toString());
}
},
),
PlatformButton( PlatformButton(
text: 'Request Permissions', text: 'Request Permissions',
onPressed: () async { onPressed: () async {
@@ -230,6 +243,7 @@ class _MyAppState extends State<MyApp> {
} }
}, },
), ),
],
if (!isTrackingAvailabilityState) if (!isTrackingAvailabilityState)
PlatformButton( PlatformButton(
text: 'Track Availability State', text: 'Track Availability State',
+1 -1
View File
@@ -16,7 +16,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6 universal_ble: 65e1257dffc557cc7991a93d253beeddc7c1dc92
PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3
@@ -195,7 +195,6 @@
BF9EC7C1208428926C8C045E /* Pods-RunnerTests.release.xcconfig */, BF9EC7C1208428926C8C045E /* Pods-RunnerTests.release.xcconfig */,
C5F6162E162B944885A4C57E /* Pods-RunnerTests.profile.xcconfig */, C5F6162E162B944885A4C57E /* Pods-RunnerTests.profile.xcconfig */,
); );
name = Pods;
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -575,6 +574,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 10.15;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
}; };
@@ -701,6 +701,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 10.15;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -721,6 +722,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 10.15;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
}; };
+4 -4
View File
@@ -214,10 +214,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -315,10 +315,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.7"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
+12
View File
@@ -71,6 +71,18 @@ class UniversalBle {
); );
} }
/// Check if has permissions.
/// [withAndroidFineLocation] is used to check fine location permission on Android 12+ (API 31+).
/// On Android lower than 12, this method will check location permission regardless of the [withAndroidFineLocation] value.
/// `Windows`, `Linux` and `Web` will always return true.
static Future<bool> hasPermissions({
bool withAndroidFineLocation = false,
}) async {
return _platform.hasPermissions(
withAndroidFineLocation: withAndroidFineLocation,
);
}
/// Request permissions. /// Request permissions.
/// if all permissions are already granted or granted by user, this method will succeed. /// if all permissions are already granted or granted by user, this method will succeed.
/// it will throw exception if permissions are denied by user. /// it will throw exception if permissions are denied by user.
@@ -431,12 +431,6 @@ class UniversalBleLinux extends UniversalBlePlatform {
.toList(); .toList();
} }
@override
Future<void> requestPermissions(
{bool withAndroidFineLocation = false}) async {
// No permissions to request on linux
}
AvailabilityState get _availabilityState { AvailabilityState get _availabilityState {
return _activeAdapter?.powered == true return _activeAdapter?.powered == true
? AvailabilityState.poweredOn ? AvailabilityState.poweredOn
@@ -592,6 +592,35 @@ class UniversalBlePlatformChannel {
} }
} }
Future<bool> hasPermissions(bool withAndroidFineLocation) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.hasPermissions$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture =
pigeonVar_channel.send(<Object?>[withAndroidFineLocation]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
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 if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
Future<void> requestPermissions(bool withAndroidFineLocation) async { Future<void> requestPermissions(bool withAndroidFineLocation) async {
final pigeonVar_channelName = final pigeonVar_channelName =
'dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions$pigeonVar_messageChannelSuffix';
@@ -145,6 +145,13 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform {
Future<void> unpair(String deviceId) => Future<void> unpair(String deviceId) =>
_executeWithErrorHandling(() => _channel.unPair(deviceId)); _executeWithErrorHandling(() => _channel.unPair(deviceId));
@override
Future<bool> hasPermissions({bool withAndroidFineLocation = false}) async {
return await _executeWithErrorHandling(
() => _channel.hasPermissions(withAndroidFineLocation),
);
}
@override @override
Future<void> requestPermissions( Future<void> requestPermissions(
{bool withAndroidFineLocation = false}) async { {bool withAndroidFineLocation = false}) async {
@@ -37,7 +37,12 @@ abstract class UniversalBlePlatform {
Future<bool> disableBluetooth(); Future<bool> disableBluetooth();
Future<void> requestPermissions({bool withAndroidFineLocation = false}); Future<bool> hasPermissions({bool withAndroidFineLocation = false}) async {
return true;
}
Future<void> requestPermissions(
{bool withAndroidFineLocation = false}) async {}
Future<void> startScan({ Future<void> startScan({
ScanFilter? scanFilter, ScanFilter? scanFilter,
@@ -275,12 +275,6 @@ class UniversalBleWeb extends UniversalBlePlatform {
} }
/// `Unimplemented` /// `Unimplemented`
@override
Future<void> requestPermissions(
{bool withAndroidFineLocation = false}) async {
// No permissions to request on Web
}
@override @override
Future<int> requestMtu(String deviceId, int expectedMtu) { Future<int> requestMtu(String deviceId, int expectedMtu) {
throw UniversalBleException( throw UniversalBleException(
+2
View File
@@ -24,6 +24,8 @@ abstract class UniversalBlePlatformChannel {
@async @async
int getBluetoothAvailabilityState(); int getBluetoothAvailabilityState();
bool hasPermissions(bool withAndroidFineLocation);
@async @async
void requestPermissions(bool withAndroidFineLocation); void requestPermissions(bool withAndroidFineLocation);
+28
View File
@@ -612,6 +612,34 @@ void UniversalBlePlatformChannel::SetUp(
channel.SetMessageHandler(nullptr); channel.SetMessageHandler(nullptr);
} }
} }
{
BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.hasPermissions" + prepended_suffix, &GetCodec());
if (api != nullptr) {
channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply<EncodableValue>& reply) {
try {
const auto& args = std::get<EncodableList>(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<bool>(encodable_with_android_fine_location_arg);
ErrorOr<bool> output = api->HasPermissions(with_android_fine_location_arg);
if (output.has_error()) {
reply(WrapError(output.error()));
return;
}
EncodableList wrapped;
wrapped.push_back(EncodableValue(std::move(output).TakeValue()));
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.requestPermissions" + prepended_suffix, &GetCodec()); BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.requestPermissions" + prepended_suffix, &GetCodec());
if (api != nullptr) { if (api != nullptr) {
+1
View File
@@ -387,6 +387,7 @@ class UniversalBlePlatformChannel {
UniversalBlePlatformChannel& operator=(const UniversalBlePlatformChannel&) = delete; UniversalBlePlatformChannel& operator=(const UniversalBlePlatformChannel&) = delete;
virtual ~UniversalBlePlatformChannel() {} virtual ~UniversalBlePlatformChannel() {}
virtual void GetBluetoothAvailabilityState(std::function<void(ErrorOr<int64_t> reply)> result) = 0; virtual void GetBluetoothAvailabilityState(std::function<void(ErrorOr<int64_t> reply)> result) = 0;
virtual ErrorOr<bool> HasPermissions(bool with_android_fine_location) = 0;
virtual void RequestPermissions( virtual void RequestPermissions(
bool with_android_fine_location, bool with_android_fine_location,
std::function<void(std::optional<FlutterError> reply)> result) = 0; std::function<void(std::optional<FlutterError> reply)> result) = 0;
+5
View File
@@ -119,6 +119,11 @@ void UniversalBlePlugin::DisableBluetooth(
}); });
} }
ErrorOr<bool> UniversalBlePlugin::HasPermissions(bool with_android_fine_location) {
// Windows does not require runtime permissions for Bluetooth
return true;
}
void UniversalBlePlugin::RequestPermissions( void UniversalBlePlugin::RequestPermissions(
bool with_android_fine_location, bool with_android_fine_location,
std::function<void(std::optional<FlutterError> reply)> result) { std::function<void(std::optional<FlutterError> reply)> result) {
+1
View File
@@ -177,6 +177,7 @@ private:
ErrorOr<bool> IsScanning() override; ErrorOr<bool> IsScanning() override;
std::optional<FlutterError> Connect(const std::string &device_id) override; std::optional<FlutterError> Connect(const std::string &device_id) override;
std::optional<FlutterError> Disconnect(const std::string &device_id) override; std::optional<FlutterError> Disconnect(const std::string &device_id) override;
ErrorOr<bool> HasPermissions(bool with_android_fine_location) override;
void RequestPermissions( void RequestPermissions(
bool with_android_fine_location, bool with_android_fine_location,
std::function<void(std::optional<FlutterError> reply)> result) override; std::function<void(std::optional<FlutterError> reply)> result) override;