Skip to content

Commit da392cd

Browse files
committed
refactor(ENV-2899): park reconnect until .poweredOn instead of polling
Replaces the retry loop (20 × 0.5s waiting for the central manager to become .poweredOn) with an event-driven approach: if `reconnect` is called while the manager isn't ready, stash the call in `pendingReconnects` and drain it from `centralManagerDidUpdateState` when the state transitions to .poweredOn. Also fail any parked calls on `cleanup()` so Flutter futures don't leak on shutdown. Addresses PR review feedback that polling masked the underlying iOS state-transition timing issue.
1 parent e8df9c4 commit da392cd

1 file changed

Lines changed: 37 additions & 13 deletions

File tree

‎ios/Runner/Ble/BluetoothChannel.swift‎

Lines changed: 37 additions & 13 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] = [
@@ -209,28 +213,30 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
209213
/// Reconnect to a previously paired device.
210214
/// prepareDevice() should be called first to register native channels.
211215
///
212-
/// If the central manager isn't `.poweredOn` yet (e.g. the user just
213-
/// toggled Bluetooth and CoreBluetooth is still `.resetting`/`.unknown`),
214-
/// we retry for up to ~10s (20 × 0.5s) waiting for `.poweredOn` before
215-
/// returning `NO_CENTRAL`. Prime advertises continuously, so the phone
216-
/// side was the only thing preventing recovery.
217-
private func reconnect(call: FlutterMethodCall, result: @escaping FlutterResult, retriesLeft: Int = 20) {
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.
223+
private func reconnect(call: FlutterMethodCall, result: @escaping FlutterResult) {
218224
guard let arguments = call.arguments as? [String: Any],
219225
let deviceId = arguments["deviceId"] as? String,
220226
!deviceId.isEmpty else {
221227
result(FlutterError(code: "INVALID_DEVICE_ID", message: "Device ID is required", details: nil))
222228
return
223229
}
224230

231+
// Make sure a CBCentralManager exists so we'll get the
232+
// `.poweredOn` transition that drains the parked calls.
233+
setupBluetoothManager()
234+
225235
guard let central = centralManager, central.state == .poweredOn else {
226-
if retriesLeft > 0 {
227-
print("\(Self.TAG) Central not ready for reconnect (\(retriesLeft) retries left); waiting 0.5s")
228-
bleQueue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
229-
self?.reconnect(call: call, result: result, retriesLeft: retriesLeft - 1)
230-
}
231-
return
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))
232239
}
233-
result(FlutterError(code: "NO_CENTRAL", message: "Central manager not available or not powered on", details: nil))
234240
return
235241
}
236242

@@ -371,6 +377,13 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
371377
// Set flag first to prevent any further Flutter channel communication
372378
isShuttingDown = true
373379

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+
374387
// Stop any pending reconnection timers
375388
stopReconnection()
376389

@@ -731,6 +744,17 @@ class BluetoothChannel: NSObject, CBCentralManagerDelegate, FlutterStreamHandler
731744
}
732745

733746
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+
734758
// Rebuild pairedAccessories from current session state.
735759
var sessionPaired: [UUID: ASAccessory] = [:]
736760
for accessory in session.accessories {

0 commit comments

Comments
 (0)