@@ -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