Add auto-connect support (#206)

* Add autoConnect support

* Enhance autoConnect functionality for Bluetooth devices

- Added support for managing a set of auto-connect devices in both Android and iOS implementations.
- Updated connection and disconnection logic to handle auto-reconnect scenarios appropriately.
- Improved cleanup processes to prevent unwanted reconnections when devices are manually disconnected.

* Refactor connection button and add auto-reconnect toggle

- Updated the connection button layout for better UI consistency.
- Introduced an auto-reconnect toggle to manage automatic reconnections when devices become available.
- Enhanced connection and disconnection logic to respect the auto-connect setting.

* Update version to 1.2.0 and add CHANGELOG for new features

- Bumped version to 1.2.0.
- Added CHANGELOG.md detailing new features including support for `autoConnect` and UI updates for automatic reconnection.

* Improve readme

* Refactor variable naming for clarity in Bluetooth connection logic

- Changed variable name from `isAutoConnect` to `shouldAutoConnect` for better readability and understanding of its purpose in the connection state handling.

* Refactor connection handling in UniversalBlePlugin

- Simplified connection logic by always cleaning up internal state before sending connection change callbacks.
- Ensured GATT resources are only closed when autoConnect is disabled, allowing for better management of Bluetooth connections.

* Refactor disconnection handling in UniversalBlePlugin

- Introduced a new method `handlePeripheralDisconnection` to encapsulate disconnection logic, improving code readability and maintainability.
- Updated the `centralManager` methods to utilize the new disconnection handler, ensuring consistent behavior across connection state changes.

* Update darwin/Classes/UniversalBlePlugin.swift

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

---------

Co-authored-by: Foti Dim <fotios.dimanidis@a2zebra.de>
Co-authored-by: Navideck Labs <130186950+navidecklabs@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Foti Dim
2026-01-16 07:06:24 +01:00
committed by GitHub
parent a30e84a70c
commit 8557a46d05
28 changed files with 323 additions and 106 deletions
+3
View File
@@ -1,3 +1,6 @@
## 1.2.0
* Add `autoConnect` parameter to `connect()` method for automatic reconnection support on Android and iOS/macOS
## 1.1.0 ## 1.1.0
* Add readRssi method * Add readRssi method
+8
View File
@@ -40,6 +40,7 @@ A cross-platform (Android/iOS/macOS/Windows/Linux/Web) Bluetooth Low Energy (BLE
| :---------------------------- | :-----: | :-: | :---: | :-----: | :---: | :-: | | :---------------------------- | :-----: | :-: | :---: | :-----: | :---: | :-: |
| startScan/stopScan | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | startScan/stopScan | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| connect/disconnect | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | connect/disconnect | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| autoConnect | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ |
| getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | | getSystemDevices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ |
| discoverServices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | discoverServices | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| read | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | | read | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
@@ -231,6 +232,13 @@ bool isConnected = await bleDevice.isConnected;
BleConnectionState connectionState = await bleDevice.connectionState; BleConnectionState connectionState = await bleDevice.connectionState;
``` ```
#### Auto-connect
You can enable automatic reconnection by setting the `autoConnect` parameter to `true`. When enabled, the system will automatically attempt to reconnect to the device when it becomes available again.
```dart
await bleDevice.connect(autoConnect: true);
```
### Discovering Services ### 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.
@@ -517,7 +517,7 @@ interface UniversalBlePlatformChannel {
fun startScan(filter: UniversalScanFilter?) fun startScan(filter: UniversalScanFilter?)
fun stopScan() fun stopScan()
fun isScanning(): Boolean fun isScanning(): Boolean
fun connect(deviceId: String) fun connect(deviceId: String, autoConnect: Boolean?)
fun disconnect(deviceId: String) fun disconnect(deviceId: String)
fun setNotifiable(deviceId: String, service: String, characteristic: String, bleInputProperty: Long, callback: (Result<Unit>) -> Unit) fun setNotifiable(deviceId: String, service: String, characteristic: String, bleInputProperty: Long, callback: (Result<Unit>) -> Unit)
fun discoverServices(deviceId: String, withDescriptors: Boolean, callback: (Result<List<UniversalBleService>>) -> Unit) fun discoverServices(deviceId: String, withDescriptors: Boolean, callback: (Result<List<UniversalBleService>>) -> Unit)
@@ -686,8 +686,9 @@ interface UniversalBlePlatformChannel {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>
val deviceIdArg = args[0] as String val deviceIdArg = args[0] as String
val autoConnectArg = args[1] as Boolean?
val wrapped: List<Any?> = try { val wrapped: List<Any?> = try {
api.connect(deviceIdArg) api.connect(deviceIdArg, autoConnectArg)
listOf(null) listOf(null)
} catch (exception: Throwable) { } catch (exception: Throwable) {
UniversalBlePigeonUtils.wrapError(exception) UniversalBlePigeonUtils.wrapError(exception)
@@ -61,6 +61,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
private val subscriptionResultFutureList = mutableListOf<SubscriptionResultFuture>() private val subscriptionResultFutureList = mutableListOf<SubscriptionResultFuture>()
private val pairResultFutures = mutableMapOf<String, (Result<Boolean>) -> Unit>() private val pairResultFutures = mutableMapOf<String, (Result<Boolean>) -> Unit>()
private val rssiResultFutureList = mutableListOf<RssiResultFuture>() private val rssiResultFutureList = mutableListOf<RssiResultFuture>()
private val autoConnectDevices = mutableSetOf<String>()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
UniversalBlePlatformChannel.setUp(flutterPluginBinding.binaryMessenger, this) UniversalBlePlatformChannel.setUp(flutterPluginBinding.binaryMessenger, this)
@@ -213,7 +214,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
return safeScanner.isScanning() return safeScanner.isScanning()
} }
override fun connect(deviceId: String) { override fun connect(deviceId: String, autoConnect: Boolean?) {
// If already connected, send connected message, // If already connected, send connected message,
// if connecting, do nothing // if connecting, do nothing
deviceId.findGatt()?.let { deviceId.findGatt()?.let {
@@ -232,24 +233,31 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
} }
} }
val shouldAutoConnect = autoConnect ?: false
if (shouldAutoConnect) {
autoConnectDevices.add(deviceId)
} else {
autoConnectDevices.remove(deviceId)
}
val remoteDevice = bluetoothManager.adapter.getRemoteDevice(deviceId) val remoteDevice = bluetoothManager.adapter.getRemoteDevice(deviceId)
val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
remoteDevice.connectGatt( remoteDevice.connectGatt(
context, context,
false, shouldAutoConnect,
this, this,
BluetoothDevice.TRANSPORT_LE BluetoothDevice.TRANSPORT_LE
) )
} else { } else {
remoteDevice.connectGatt(context, false, this) remoteDevice.connectGatt(context, shouldAutoConnect, this)
} }
gatt.saveCacheIfNeeded() gatt.saveCacheIfNeeded()
} }
override fun disconnect(deviceId: String) { override fun disconnect(deviceId: String) {
autoConnectDevices.remove(deviceId)
val gatt = deviceId.findGatt() val gatt = deviceId.findGatt()
if (gatt == null) { if (gatt == null) {
cleanUpConnection(deviceId)
mainThreadHandler?.post { mainThreadHandler?.post {
callbackChannel?.onConnectionChanged(deviceId, false, null) {} callbackChannel?.onConnectionChanged(deviceId, false, null) {}
} }
@@ -955,15 +963,13 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
} }
} }
private fun cleanConnection(gatt: BluetoothGatt) { private fun cleanUpConnection(deviceId: String) {
gatt.removeCache()
gatt.disconnect()
val deviceDisconnectedError: FlutterError = createFlutterError( val deviceDisconnectedError: FlutterError = createFlutterError(
UniversalBleErrorCode.DEVICE_DISCONNECTED, UniversalBleErrorCode.DEVICE_DISCONNECTED,
"Device Disconnected", "Device Disconnected",
) )
readResultFutureList.removeAll { readResultFutureList.removeAll {
if (it.deviceId == gatt.device.address) { if (it.deviceId == deviceId) {
it.result(Result.failure(deviceDisconnectedError)) it.result(Result.failure(deviceDisconnectedError))
true true
} else { } else {
@@ -971,7 +977,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
} }
} }
writeResultFutureList.removeAll { writeResultFutureList.removeAll {
if (it.deviceId == gatt.device.address) { if (it.deviceId == deviceId) {
it.result(Result.failure(deviceDisconnectedError)) it.result(Result.failure(deviceDisconnectedError))
true true
} else { } else {
@@ -979,7 +985,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
} }
} }
subscriptionResultFutureList.removeAll { subscriptionResultFutureList.removeAll {
if (it.deviceId == gatt.device.address) { if (it.deviceId == deviceId) {
it.result(Result.failure(deviceDisconnectedError)) it.result(Result.failure(deviceDisconnectedError))
true true
} else { } else {
@@ -987,7 +993,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
} }
} }
mtuResultFutureList.removeAll { mtuResultFutureList.removeAll {
if (it.deviceId == gatt.device.address) { if (it.deviceId == deviceId) {
it.result(Result.failure(deviceDisconnectedError)) it.result(Result.failure(deviceDisconnectedError))
true true
} else { } else {
@@ -995,7 +1001,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
} }
} }
discoverServicesFutureList.removeAll { discoverServicesFutureList.removeAll {
if (it.deviceId == gatt.device.address) { if (it.deviceId == deviceId) {
it.result(Result.failure(deviceDisconnectedError)) it.result(Result.failure(deviceDisconnectedError))
true true
} else { } else {
@@ -1003,7 +1009,7 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
} }
} }
rssiResultFutureList.removeAll { rssiResultFutureList.removeAll {
if (it.deviceId == gatt.device.address) { if (it.deviceId == deviceId) {
it.result(Result.failure(deviceDisconnectedError)) it.result(Result.failure(deviceDisconnectedError))
true true
} else { } else {
@@ -1012,6 +1018,12 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
} }
} }
private fun cleanConnection(gatt: BluetoothGatt) {
gatt.removeCache()
gatt.disconnect()
cleanUpConnection(gatt.device.address)
}
private fun onBondStateUpdate(deviceId: String, bonded: Boolean, error: String? = null) { private fun onBondStateUpdate(deviceId: String, bonded: Boolean, error: String? = null) {
val future = pairResultFutures.remove(deviceId) val future = pairResultFutures.remove(deviceId)
future?.let { it(Result.success(bonded)) } future?.let { it(Result.success(bonded)) }
@@ -1134,14 +1146,27 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(),
) {} ) {}
} }
} else if (newState == BluetoothGatt.STATE_DISCONNECTED) { } else if (newState == BluetoothGatt.STATE_DISCONNECTED) {
cleanConnection(gatt) val deviceId = gatt.device.address
val shouldAutoConnect = autoConnectDevices.contains(deviceId)
// Always clean up internal state (futures, etc.)
cleanUpConnection(deviceId)
// Send connection changed callback
mainThreadHandler?.post { mainThreadHandler?.post {
callbackChannel?.onConnectionChanged( callbackChannel?.onConnectionChanged(
gatt.device.address, false, status.parseHciErrorCode() deviceId, false, status.parseHciErrorCode()
) {} ) {}
} }
UniversalBleLogger.logDebug("Closing gatt for ${gatt.device.name}")
gatt.close() if (!shouldAutoConnect) {
// Only close GATT resources when autoConnect is disabled
gatt.removeCache()
gatt.disconnect()
UniversalBleLogger.logDebug("Closing gatt for ${gatt.device.name}")
gatt.close()
}
// When autoConnect is enabled, keep GATT open for Android to reconnect
} }
} }
+3 -2
View File
@@ -536,7 +536,7 @@ protocol UniversalBlePlatformChannel {
func startScan(filter: UniversalScanFilter?) throws func startScan(filter: UniversalScanFilter?) throws
func stopScan() throws func stopScan() throws
func isScanning() throws -> Bool func isScanning() throws -> Bool
func connect(deviceId: String) throws func connect(deviceId: String, autoConnect: Bool?) throws
func disconnect(deviceId: String) throws func disconnect(deviceId: String) throws
func setNotifiable(deviceId: String, service: String, characteristic: String, bleInputProperty: Int64, completion: @escaping (Result<Void, Error>) -> Void) func setNotifiable(deviceId: String, service: String, characteristic: String, bleInputProperty: Int64, completion: @escaping (Result<Void, Error>) -> Void)
func discoverServices(deviceId: String, withDescriptors: Bool, completion: @escaping (Result<[UniversalBleService], Error>) -> Void) func discoverServices(deviceId: String, withDescriptors: Bool, completion: @escaping (Result<[UniversalBleService], Error>) -> Void)
@@ -681,8 +681,9 @@ class UniversalBlePlatformChannelSetup {
connectChannel.setMessageHandler { message, reply in connectChannel.setMessageHandler { message, reply in
let args = message as! [Any?] let args = message as! [Any?]
let deviceIdArg = args[0] as! String let deviceIdArg = args[0] as! String
let autoConnectArg: Bool? = nilOrValue(args[1])
do { do {
try api.connect(deviceId: deviceIdArg) try api.connect(deviceId: deviceIdArg, autoConnect: autoConnectArg)
reply(wrapResult(nil)) reply(wrapResult(nil))
} catch { } catch {
reply(wrapError(error)) reply(wrapError(error))
+56 -5
View File
@@ -42,6 +42,7 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral
private var discoverServicesFutures = [DiscoverServicesFuture]() private var discoverServicesFutures = [DiscoverServicesFuture]()
private var rssiReadFutures = [RssiReadFuture]() private var rssiReadFutures = [RssiReadFuture]()
private var isManageScanning = false private var isManageScanning = false
private var autoConnectDevices = Set<String>()
init(callbackChannel: UniversalBleCallbackChannel) { init(callbackChannel: UniversalBleCallbackChannel) {
self.callbackChannel = callbackChannel self.callbackChannel = callbackChannel
@@ -129,15 +130,41 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral
UniversalBleLogger.shared.setLogLevel(logLevel) UniversalBleLogger.shared.setLogLevel(logLevel)
} }
func connect(deviceId: String) throws { func connect(deviceId: String, autoConnect: Bool?) throws {
let peripheral = try deviceId.getPeripheral(manager: manager) let peripheral = try deviceId.getPeripheral(manager: manager)
peripheral.delegate = self peripheral.delegate = self
manager.connect(peripheral) let shouldAutoConnect = autoConnect ?? false
if shouldAutoConnect {
autoConnectDevices.insert(deviceId)
if #available(iOS 17.0, macOS 14.0, watchOS 10.0, tvOS 17.0, *) {
let options: [String: Any] = [CBConnectPeripheralOptionEnableAutoReconnect: true]
manager.connect(peripheral, options: options)
} else {
// Auto-reconnect via CBConnectPeripheralOptionEnableAutoReconnect is only
// available on iOS 17.0 / macOS 14.0 / watchOS 10.0 / tvOS 17.0 and later.
// On earlier OS versions, enabling `autoConnect` will NOT provide automatic
// reconnection behavior. Any desired reconnection must be handled manually
// (e.g., in central manager delegate callbacks).
UniversalBleLogger.shared.logInfo(
"autoConnect requested for device \(deviceId), " +
"but automatic reconnection via CBConnectPeripheralOptionEnableAutoReconnect " +
"is only available on iOS 17+/macOS 14+/watchOS 10+/tvOS 17+. " +
"On this OS version, reconnections must be handled manually."
)
manager.connect(peripheral)
}
} else {
autoConnectDevices.remove(deviceId)
manager.connect(peripheral)
}
} }
func disconnect(deviceId: String) throws { func disconnect(deviceId: String) throws {
autoConnectDevices.remove(deviceId)
guard let peripheral = deviceId.findPeripheral(manager: manager) else { guard let peripheral = deviceId.findPeripheral(manager: manager) else {
callbackChannel.onConnectionChanged(deviceId: deviceId, connected: false, error: nil) { _ in } callbackChannel.onConnectionChanged(deviceId: deviceId, connected: false, error: nil) { _ in }
cleanUpConnection(deviceId: deviceId)
return return
} }
if peripheral.state != CBPeripheralState.disconnected { if peripheral.state != CBPeripheralState.disconnected {
@@ -445,9 +472,33 @@ private class BleCentralDarwin: NSObject, UniversalBlePlatformChannel, CBCentral
callbackChannel.onConnectionChanged(deviceId: peripheral.uuid.uuidString, connected: true, error: nil) { _ in } callbackChannel.onConnectionChanged(deviceId: peripheral.uuid.uuidString, connected: true, error: nil) { _ in }
} }
public func centralManager(_: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error _: Error?) { private func handlePeripheralDisconnection(deviceId: String, error: Error?) {
callbackChannel.onConnectionChanged(deviceId: peripheral.uuid.uuidString, connected: false, error: nil) { _ in } autoConnectDevices.remove(deviceId)
cleanUpConnection(deviceId: peripheral.uuid.uuidString) callbackChannel.onConnectionChanged(deviceId: deviceId, connected: false, error: error?.localizedDescription) { _ in }
cleanUpConnection(deviceId: deviceId)
}
public func centralManager(
_: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
timestamp: CFAbsoluteTime,
isReconnecting: Bool,
error: Error?
) {
let deviceId = peripheral.uuid.uuidString
if #available(iOS 17.0, macOS 14.0, watchOS 10.0, tvOS 17.0, *) {
if isReconnecting {
return
}
}
handlePeripheralDisconnection(deviceId: deviceId, error: error)
}
public func centralManager(_: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
let deviceId = peripheral.uuid.uuidString
handlePeripheralDisconnection(deviceId: deviceId, error: error)
} }
public func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { public func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
+8
View File
@@ -0,0 +1,8 @@
## 1.1.0
* Add support for `autoConnect` parameter
* Display RSSI values in device details
* Persist filters
* Fix clear log button
## 1.0.0
* Initial release
+1 -1
View File
@@ -52,7 +52,7 @@ class MockUniversalBle extends UniversalBlePlatform {
} }
@override @override
Future<void> connect(String deviceId, {Duration? connectionTimeout}) async { Future<void> connect(String deviceId, {bool autoConnect = false, Duration? connectionTimeout}) async {
updateConnection(deviceId, true); updateConnection(deviceId, true);
_connectionStateMap[deviceId] = BleConnectionState.connected; _connectionStateMap[deviceId] = BleConnectionState.connected;
} }
+1 -1
View File
@@ -77,7 +77,7 @@ class _AppDrawerState extends State<AppDrawer> {
applicationName: 'Universal BLE', applicationName: 'Universal BLE',
applicationVersion: applicationVersion:
"${snapshot.data?.version} (${snapshot.data?.buildNumber})", "${snapshot.data?.version} (${snapshot.data?.buildNumber})",
applicationLegalese: '\u{a9} 2025 Navideck', applicationLegalese: '\u{a9} 2023 Navideck',
aboutBoxChildren: [ aboutBoxChildren: [
const SizedBox(height: 24), const SizedBox(height: 24),
RichText( RichText(
@@ -32,6 +32,7 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
bool _isDeviceInfoExpanded = false; bool _isDeviceInfoExpanded = false;
bool _isDeviceActionsExpanded = true; bool _isDeviceActionsExpanded = true;
final Map<String, bool> _subscribedCharacteristics = {}; final Map<String, bool> _subscribedCharacteristics = {};
bool _autoConnect = false;
StreamSubscription? connectionStreamSubscription; StreamSubscription? connectionStreamSubscription;
StreamSubscription? pairingStateSubscription; StreamSubscription? pairingStateSubscription;
@@ -688,59 +689,155 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
Widget _buildConnectDisconnectButton() { Widget _buildConnectDisconnectButton() {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return SizedBox( return Padding(
width: double.infinity, padding: const EdgeInsets.symmetric(
child: Padding( horizontal: 16.0,
padding: const EdgeInsets.symmetric( vertical: 8.0,
horizontal: 16.0, ),
vertical: 8.0, child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
child: ElevatedButton.icon( children: [
onPressed: () async { // AutoConnect toggle
if (isConnected) { Card(
await _executeWithLoading( elevation: 1,
() async {
await bleDevice.disconnect();
_addLog("DisconnectResult", true);
},
onError: (error) {
_addLog('DisconnectError', error);
},
);
} else {
await _executeWithLoading(
() async {
await bleDevice.connect();
_addLog("ConnectionResult", true);
},
onError: (error) {
_addLog(
'ConnectError (${error.runtimeType})',
error,
);
},
);
}
},
icon: Icon(
isConnected ? Icons.bluetooth_disabled : Icons.bluetooth_connected,
size: 20,
),
label: Text(isConnected ? 'Disconnect' : 'Connect'),
style: ElevatedButton.styleFrom(
backgroundColor:
isConnected ? colorScheme.error : colorScheme.primary,
foregroundColor:
isConnected ? colorScheme.onError : colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
vertical: 16,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
elevation: 2, color: colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
Icon(
Icons.autorenew,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Auto Reconnect',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
Text(
'Automatically reconnect when device becomes available',
style: TextStyle(
fontSize: 11,
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
Switch(
value: _autoConnect,
onChanged: (value) async {
// If toggling off auto-connect, disconnect to prevent unwanted reconnections
if (!value && _autoConnect) {
if (isConnected) {
// Device is connected, disconnect it to prevent auto-reconnect
await _executeWithLoading(
() async {
await bleDevice.disconnect();
_addLog("DisconnectResult",
"Disconnected to disable auto-reconnect");
},
onError: (error) {
_addLog('DisconnectError', error);
},
);
} else {
// Device is already disconnected, but call disconnect() anyway
// to ensure cleanup and prevent any pending auto-reconnection attempts
await _executeWithLoading(
() async {
await bleDevice.disconnect();
_addLog("DisconnectResult",
"Cleanup performed to prevent auto-reconnect");
},
onError: (error) {
_addLog('DisconnectError', error);
},
);
}
}
setState(() {
_autoConnect = value;
});
},
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
),
),
), ),
), const SizedBox(height: 12),
// Connect/Disconnect button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () async {
if (isConnected) {
await _executeWithLoading(
() async {
await bleDevice.disconnect();
_addLog("DisconnectResult", true);
},
onError: (error) {
_addLog('DisconnectError', error);
},
);
} else {
await _executeWithLoading(
() async {
await bleDevice.connect(autoConnect: _autoConnect);
_addLog(
"ConnectionResult",
"Connected${_autoConnect ? ' (Auto-reconnect enabled)' : ''}",
);
},
onError: (error) {
_addLog(
'ConnectError (${error.runtimeType})',
error,
);
},
);
}
},
icon: Icon(
isConnected
? Icons.bluetooth_disabled
: Icons.bluetooth_connected,
size: 20,
),
label: Text(isConnected ? 'Disconnect' : 'Connect'),
style: ElevatedButton.styleFrom(
backgroundColor:
isConnected ? colorScheme.error : colorScheme.primary,
foregroundColor:
isConnected ? colorScheme.onError : colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
),
),
],
), ),
); );
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import 'package:universal_ble_example/home/scanner_screen.dart';
Future<bool> initializeApp() async { Future<bool> initializeApp() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await StorageService.instance.init(); await StorageService.instance.init();
// await UniversalBle.setLogLevel(BleLogLevel.verbose); await UniversalBle.setLogLevel(BleLogLevel.verbose);
return await UniversalBle.hasPermissions( return await UniversalBle.hasPermissions(
withAndroidFineLocation: false, withAndroidFineLocation: false,
); );
@@ -11,4 +11,4 @@ PRODUCT_NAME = Universal BLE
PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble PRODUCT_BUNDLE_IDENTIFIER = com.navideck.universalble
// The copyright displayed in application information // The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2025 com.navideck. All rights reserved. PRODUCT_COPYRIGHT = Copyright © 2023 com.navideck. All rights reserved.
+2 -2
View File
@@ -18,12 +18,12 @@ package_rename_config:
macos: macos:
app_name: "Universal BLE" app_name: "Universal BLE"
package_name: "com.navideck.universalble" package_name: "com.navideck.universalble"
copyright_notice: "Copyright © 2025 com.navideck. All rights reserved." copyright_notice: "Copyright © 2023 com.navideck. All rights reserved."
windows: windows:
app_name: "Universal BLE" app_name: "Universal BLE"
organization: "Navideck" organization: "Navideck"
copyright_notice: "Copyright © 2025 com.navideck. All rights reserved." copyright_notice: "Copyright © 2023 com.navideck. All rights reserved."
exe_name: "universal_ble" exe_name: "universal_ble"
web: web:
+1 -1
View File
@@ -93,7 +93,7 @@ BEGIN
VALUE "FileDescription", "Universal BLE" "\0" VALUE "FileDescription", "Universal BLE" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "Universal BLE" "\0" VALUE "InternalName", "Universal BLE" "\0"
VALUE "LegalCopyright", "Copyright © 2025 com.navideck. All rights reserved." "\0" VALUE "LegalCopyright", "Copyright © 2023 com.navideck. All rights reserved." "\0"
VALUE "OriginalFilename", "universal_ble.exe" "\0" VALUE "OriginalFilename", "universal_ble.exe" "\0"
VALUE "ProductName", "Universal BLE" "\0" VALUE "ProductName", "Universal BLE" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"
+3 -1
View File
@@ -17,7 +17,9 @@ extension BleDeviceExtension on BleDevice {
BleConnectionState.connected; BleConnectionState.connected;
/// Connects to the device. /// Connects to the device.
Future<void> connect() => UniversalBle.connect(deviceId); /// [autoConnect] enables automatic reconnection when the device becomes available.
Future<void> connect({bool autoConnect = false, Duration? timeout}) =>
UniversalBle.connect(deviceId, autoConnect: autoConnect, timeout: timeout);
/// Disconnects from the device. /// Disconnects from the device.
Future<void> disconnect() => UniversalBle.disconnect(deviceId); Future<void> disconnect() => UniversalBle.disconnect(deviceId);
+22 -10
View File
@@ -134,16 +134,26 @@ class UniversalBle {
/// It is advised to stop scanning before connecting. /// It is advised to stop scanning before connecting.
/// It throws error if device connection fails. /// It throws error if device connection fails.
/// Default connection timeout is 60 sec. /// Default connection timeout is 60 sec.
///
/// [autoConnect] enables automatic reconnection when the device becomes available.
/// Default value is `false`.
/// Ignored on `Windows`, `Linux` and `Web`.
///
/// Call [disconnect] to prevent auto-reconnect even while a device is disconnected.
///
/// Can throw `ConnectionException` or `PlatformException`. /// Can throw `ConnectionException` or `PlatformException`.
static Future<void> connect( static Future<void> connect(
String deviceId, { String deviceId, {
Duration? timeout, Duration? timeout,
bool autoConnect = false,
}) async { }) async {
timeout ??= const Duration(seconds: 60); timeout ??= const Duration(seconds: 60);
Completer<bool> completer = Completer<bool> completer =
_connectionEventCompleter(deviceId, timeout: timeout); _connectionEventCompleter(deviceId, timeout: timeout);
_platform.connect(deviceId, connectionTimeout: timeout).catchError( _platform
.connect(deviceId, connectionTimeout: timeout, autoConnect: autoConnect)
.catchError(
(error) { (error) {
if (completer.isCompleted) return; if (completer.isCompleted) return;
completer.completeError(ConnectionException(error)); completer.completeError(ConnectionException(error));
@@ -169,15 +179,6 @@ class UniversalBle {
UniversalLogger.logError("Get connection state failed: $e"); UniversalLogger.logError("Get connection state failed: $e");
} }
if (connectionState == BleConnectionState.disconnected ||
connectionState == BleConnectionState.disconnecting) {
_platform.updateConnection(deviceId, false);
UniversalLogger.logInfo(
"Device $deviceId already disconnected: $connectionState",
);
return;
}
try { try {
Completer<bool> completer = Completer<bool> completer =
_connectionEventCompleter(deviceId, timeout: timeout); _connectionEventCompleter(deviceId, timeout: timeout);
@@ -192,6 +193,17 @@ class UniversalBle {
}, },
); );
if (connectionState == BleConnectionState.disconnected ||
connectionState == BleConnectionState.disconnecting) {
// Device was already disconnected, but we still called platform disconnect
// to prevent auto-reconnect. Update connection state and return.
_platform.updateConnection(deviceId, false);
UniversalLogger.logInfo(
"Device $deviceId already disconnected: $connectionState. Cleanup performed to prevent auto-reconnect.",
);
return;
}
if (await completer.future.timeout(timeout)) { if (await completer.future.timeout(timeout)) {
UniversalLogger.logError( UniversalLogger.logError(
"Device $deviceId is still connected after disconnect attempt", "Device $deviceId is still connected after disconnect attempt",
@@ -141,7 +141,8 @@ class UniversalBleLinux extends UniversalBlePlatform {
} }
@override @override
Future<void> connect(String deviceId, {Duration? connectionTimeout}) async { Future<void> connect(String deviceId, {Duration? connectionTimeout, bool autoConnect = false}) async {
// Note: autoConnect is not directly supported on Linux platform
final device = _findDeviceById(deviceId); final device = _findDeviceById(deviceId);
if (device.connected) { if (device.connected) {
updateConnection(deviceId, true); updateConnection(deviceId, true);
@@ -776,7 +776,7 @@ class UniversalBlePlatformChannel {
} }
} }
Future<void> connect(String deviceId) async { Future<void> connect(String deviceId, {bool? autoConnect}) async {
final pigeonVar_channelName = final pigeonVar_channelName =
'dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.connect$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.universal_ble.UniversalBlePlatformChannel.connect$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -785,7 +785,7 @@ class UniversalBlePlatformChannel {
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = final Future<Object?> pigeonVar_sendFuture =
pigeonVar_channel.send(<Object?>[deviceId]); pigeonVar_channel.send(<Object?>[deviceId, autoConnect]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);
@@ -69,8 +69,8 @@ class UniversalBlePigeonChannel extends UniversalBlePlatform {
} }
@override @override
Future<void> connect(String deviceId, {Duration? connectionTimeout}) => Future<void> connect(String deviceId, {Duration? connectionTimeout, bool autoConnect = false}) =>
_executeWithErrorHandling(() => _channel.connect(deviceId)); _executeWithErrorHandling(() => _channel.connect(deviceId, autoConnect: autoConnect));
@override @override
Future<void> disconnect(String deviceId) => Future<void> disconnect(String deviceId) =>
@@ -53,7 +53,7 @@ abstract class UniversalBlePlatform {
Future<bool> isScanning(); Future<bool> isScanning();
Future<void> connect(String deviceId, {Duration? connectionTimeout}); Future<void> connect(String deviceId, {Duration? connectionTimeout, bool autoConnect = false});
Future<void> disconnect(String deviceId); Future<void> disconnect(String deviceId);
@@ -35,7 +35,9 @@ class UniversalBleWeb extends UniversalBlePlatform {
Future<void> connect( Future<void> connect(
String deviceId, { String deviceId, {
Duration? connectionTimeout = const Duration(seconds: 10), Duration? connectionTimeout = const Duration(seconds: 10),
bool autoConnect = false,
}) async { }) async {
// Note: autoConnect is not directly supported on Web platform
var device = _getDeviceById(deviceId); var device = _getDeviceById(deviceId);
if (device == null) { if (device == null) {
throw UniversalBleException( throw UniversalBleException(
+1 -1
View File
@@ -41,7 +41,7 @@ abstract class UniversalBlePlatformChannel {
bool isScanning(); bool isScanning();
void connect(String deviceId); void connect(String deviceId, {bool? autoConnect});
void disconnect(String deviceId); void disconnect(String deviceId);
+1 -1
View File
@@ -1,6 +1,6 @@
name: universal_ble name: universal_ble
description: A cross-platform (Android/iOS/macOS/Windows/Linux/Web) Bluetooth Low Energy (BLE) plugin for Flutter description: A cross-platform (Android/iOS/macOS/Windows/Linux/Web) Bluetooth Low Energy (BLE) plugin for Flutter
version: 1.1.0 version: 1.2.0
homepage: https://navideck.com homepage: https://navideck.com
repository: https://github.com/Navideck/universal_ble repository: https://github.com/Navideck/universal_ble
issue_tracker: https://github.com/Navideck/universal_ble/issues issue_tracker: https://github.com/Navideck/universal_ble/issues
+2 -1
View File
@@ -3,7 +3,8 @@ import 'package:universal_ble/universal_ble.dart';
abstract class UniversalBlePlatformMock extends UniversalBlePlatform { abstract class UniversalBlePlatformMock extends UniversalBlePlatform {
@override @override
Future<void> connect(String deviceId, {Duration? connectionTimeout}) { Future<void> connect(String deviceId,
{bool autoConnect = false, Duration? connectionTimeout}) {
throw UnimplementedError(); throw UnimplementedError();
} }
+3 -1
View File
@@ -791,7 +791,9 @@ void UniversalBlePlatformChannel::SetUp(
return; return;
} }
const auto& device_id_arg = std::get<std::string>(encodable_device_id_arg); const auto& device_id_arg = std::get<std::string>(encodable_device_id_arg);
std::optional<FlutterError> output = api->Connect(device_id_arg); const auto& encodable_auto_connect_arg = args.at(1);
const auto* auto_connect_arg = std::get_if<bool>(&encodable_auto_connect_arg);
std::optional<FlutterError> output = api->Connect(device_id_arg, auto_connect_arg);
if (output.has_value()) { if (output.has_value()) {
reply(WrapError(output.value())); reply(WrapError(output.value()));
return; return;
+3 -1
View File
@@ -396,7 +396,9 @@ class UniversalBlePlatformChannel {
virtual std::optional<FlutterError> StartScan(const UniversalScanFilter* filter) = 0; virtual std::optional<FlutterError> StartScan(const UniversalScanFilter* filter) = 0;
virtual std::optional<FlutterError> StopScan() = 0; virtual std::optional<FlutterError> StopScan() = 0;
virtual ErrorOr<bool> IsScanning() = 0; virtual ErrorOr<bool> IsScanning() = 0;
virtual std::optional<FlutterError> Connect(const std::string& device_id) = 0; virtual std::optional<FlutterError> Connect(
const std::string& device_id,
const bool* auto_connect) = 0;
virtual std::optional<FlutterError> Disconnect(const std::string& device_id) = 0; virtual std::optional<FlutterError> Disconnect(const std::string& device_id) = 0;
virtual void SetNotifiable( virtual void SetNotifiable(
const std::string& device_id, const std::string& device_id,
+2 -1
View File
@@ -261,7 +261,8 @@ UniversalBlePlugin::SetLogLevel(const UniversalBleLogLevel &log_level) {
} }
std::optional<FlutterError> std::optional<FlutterError>
UniversalBlePlugin::Connect(const std::string &device_id) { UniversalBlePlugin::Connect(const std::string &device_id, const bool *auto_connect) {
// Note: autoConnect is not directly supported on Windows platform
ConnectAsync(str_to_mac_address(device_id)); ConnectAsync(str_to_mac_address(device_id));
return std::nullopt; return std::nullopt;
}; };
+1 -1
View File
@@ -175,7 +175,7 @@ private:
StartScan(const UniversalScanFilter *filter) override; StartScan(const UniversalScanFilter *filter) override;
std::optional<FlutterError> StopScan() override; std::optional<FlutterError> StopScan() override;
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, const bool *auto_connect) 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; ErrorOr<bool> HasPermissions(bool with_android_fine_location) override;
void RequestPermissions( void RequestPermissions(