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