Skip to content

Commit 632e171

Browse files
authored
Merge pull request #1235 from Foundation-Devices/jack/env-2899-ios-reconnect-hardening
[AI][2.2.14] Harden iOS BLE reconnect around BT toggles (ENV-2899)
2 parents b6eb9ca + da392cd commit 632e171

1 file changed

Lines changed: 43 additions & 9 deletions

File tree

‎ios/Runner/Ble/BluetoothChannel.swift‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
6262
private var reconnectionAttempts: Int = 0
6363
private var isShuttingDown = false
6464

65+
// Reconnect calls received before `centralManager` reaches `.poweredOn`.
66+
// Drained from `handleBluetoothPoweredOn`. Only touched on `bleQueue`.
67+
private var pendingReconnects: [(FlutterMethodCall, FlutterResult)] = []
68+
6569
private let bleQueue = DispatchQueue(label: "com.envoy.ble", qos: .userInteractive)
6670

6771
let connectOptions: [String: Any] = [
@@ -134,6 +138,7 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
134138
override init() {
135139
super.init()
136140
setupAccessorySession()
141+
setupBluetoothManager()
137142
}
138143

139144
// MARK: - QLConnectionDelegate
@@ -207,6 +212,14 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
207212

208213
/// Reconnect to a previously paired device.
209214
/// prepareDevice() should be called first to register native channels.
215+
///
216+
/// If the central manager isn't `.poweredOn` yet (e.g. right after app
217+
/// launch when CoreBluetooth is still transitioning from `.unknown`, or
218+
/// after the user toggles Bluetooth while `.resetting`), the call is
219+
/// parked in `pendingReconnects` and drained from
220+
/// `centralManagerDidUpdateState` when the manager transitions to
221+
/// `.poweredOn`. Prime advertises continuously, so the phone side is
222+
/// the only thing preventing recovery.
210223
private func reconnect(call: FlutterMethodCall, result: @escaping FlutterResult) {
211224
guard let arguments = call.arguments as? [String: Any],
212225
let deviceId = arguments["deviceId"] as? String,
@@ -215,8 +228,15 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
215228
return
216229
}
217230

231+
// Make sure a CBCentralManager exists so we'll get the
232+
// `.poweredOn` transition that drains the parked calls.
233+
setupBluetoothManager()
234+
218235
guard let central = centralManager, central.state == .poweredOn else {
219-
result(FlutterError(code: "NO_CENTRAL", message: "Central manager not available or not powered on", details: nil))
236+
print("\(Self.TAG) Central not ready for reconnect; parking call until .poweredOn")
237+
bleQueue.async { [weak self] in
238+
self?.pendingReconnects.append((call, result))
239+
}
220240
return
221241
}
222242

@@ -357,6 +377,13 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
357377
// Set flag first to prevent any further Flutter channel communication
358378
isShuttingDown = true
359379

380+
// Fail any parked reconnect calls so Flutter futures don't leak.
381+
let parked = pendingReconnects
382+
pendingReconnects.removeAll()
383+
for (_, result) in parked {
384+
result(FlutterError(code: "NO_CENTRAL", message: "Bluetooth channel shutting down", details: nil))
385+
}
386+
360387
// Stop any pending reconnection timers
361388
stopReconnection()
362389

@@ -570,10 +597,8 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
570597
print("\(Self.TAG) - Bluetooth ID: \(accessory.bluetoothIdentifier?.uuidString ?? "None")")
571598
print("\(Self.TAG) - Has Bluetooth ID: \(hasBluetoothId)")
572599

573-
// Initialize CoreBluetooth manager if needed
574-
if centralManager == nil {
575-
setupBluetoothManager()
576-
}
600+
// No-op if already initialized (setupBluetoothManager guards on centralManager == nil).
601+
setupBluetoothManager()
577602

578603
// Create QLConnection for this accessory if it has a Bluetooth ID
579604
var deviceId: String? = nil
@@ -610,10 +635,8 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
610635
return
611636
}
612637

613-
// Initialize CoreBluetooth manager if needed
614-
if centralManager == nil {
615-
setupBluetoothManager()
616-
}
638+
// No-op if already initialized (setupBluetoothManager guards on centralManager == nil).
639+
setupBluetoothManager()
617640

618641
for accessory in accessories {
619642
guard let bluetoothId = accessory.bluetoothIdentifier else {
@@ -721,6 +744,17 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
721744
}
722745

723746
private func handleBluetoothPoweredOn(central: CBCentralManager) {
747+
// Drain any reconnect calls that arrived before the central was ready.
748+
// Already on `bleQueue` here, so no dispatch is needed.
749+
if !pendingReconnects.isEmpty {
750+
let parked = pendingReconnects
751+
pendingReconnects.removeAll()
752+
print("\(Self.TAG) Draining \(parked.count) parked reconnect call(s)")
753+
for (call, result) in parked {
754+
reconnect(call: call, result: result)
755+
}
756+
}
757+
724758
// Rebuild pairedAccessories from current session state.
725759
var sessionPaired: [UUID: ASAccessory] = [:]
726760
for accessory in session.accessories {

0 commit comments

Comments
 (0)