From 687ca9c9d68050c38b6099763db9bf02dd1cac1e Mon Sep 17 00:00:00 2001 From: Pandu Abbiyuarsyah Date: Fri, 24 Jul 2026 18:57:24 +0200 Subject: [PATCH] fix: detect and close connections on physical device disconnect --- .../FlutterClassicBluetoothPlugin.kt | 13 ++++++ .../receivers/AclDisconnectReceiver.kt | 42 +++++++++++++++++++ .../FlutterClassicBluetoothPlugin.swift | 28 ++++++++++++- 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 android/src/main/kotlin/com/flutter_classic_bluetooth/flutter_classic_bluetooth/receivers/AclDisconnectReceiver.kt diff --git a/android/src/main/kotlin/com/flutter_classic_bluetooth/flutter_classic_bluetooth/FlutterClassicBluetoothPlugin.kt b/android/src/main/kotlin/com/flutter_classic_bluetooth/flutter_classic_bluetooth/FlutterClassicBluetoothPlugin.kt index db89a2c..9d186cc 100644 --- a/android/src/main/kotlin/com/flutter_classic_bluetooth/flutter_classic_bluetooth/FlutterClassicBluetoothPlugin.kt +++ b/android/src/main/kotlin/com/flutter_classic_bluetooth/flutter_classic_bluetooth/FlutterClassicBluetoothPlugin.kt @@ -6,6 +6,7 @@ import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build import android.os.Handler @@ -48,6 +49,11 @@ class FlutterClassicBluetoothPlugin : // (platform) thread; Bluetooth I/O runs on background threads. private val mainHandler = Handler(Looper.getMainLooper()) + // Detects when a remote Bluetooth device physically disconnects (e.g. + // printer turned off) and emits "disconnected" on the matching connection's + // state stream. + private var aclDisconnectReceiver: AclDisconnectReceiver? = null + // Event channels private lateinit var adapterStateChannel: EventChannel private lateinit var discoveryStateChannel: EventChannel @@ -75,10 +81,17 @@ class FlutterClassicBluetoothPlugin : bondStateChannel = EventChannel(messenger, BluetoothHelper.BOND_STATE_CHANNEL) bondStateChannel.setStreamHandler(BondStateReceiver(context)) + + aclDisconnectReceiver = AclDisconnectReceiver(connections) + BluetoothHelper.registerExportedReceiver( + context, aclDisconnectReceiver!!, IntentFilter(BluetoothDevice.ACTION_ACL_DISCONNECTED) + ) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) + aclDisconnectReceiver?.let { context.unregisterReceiver(it) } + aclDisconnectReceiver = null closeAll() } diff --git a/android/src/main/kotlin/com/flutter_classic_bluetooth/flutter_classic_bluetooth/receivers/AclDisconnectReceiver.kt b/android/src/main/kotlin/com/flutter_classic_bluetooth/flutter_classic_bluetooth/receivers/AclDisconnectReceiver.kt new file mode 100644 index 0000000..e520630 --- /dev/null +++ b/android/src/main/kotlin/com/flutter_classic_bluetooth/flutter_classic_bluetooth/receivers/AclDisconnectReceiver.kt @@ -0,0 +1,42 @@ +package com.flutter_classic_bluetooth.flutter_classic_bluetooth.receivers + +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.SparseArray +import com.flutter_classic_bluetooth.flutter_classic_bluetooth.BluetoothConnectionWrapper + +// Listens for system ACL disconnection events so we can immediately close the +// matching connection when a remote device goes away (e.g. printer turned off +// or moves out of range). The wrapper's close() emits "disconnected" on its +// state stream, so Dart is notified without extra plumbing here. +class AclDisconnectReceiver( + private val connections: SparseArray +) : BroadcastReceiver() { + + @Suppress("MissingPermission") + override fun onReceive(ctx: Context, intent: Intent) { + if (intent.action != BluetoothDevice.ACTION_ACL_DISCONNECTED) return + + val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } ?: return + + val address = device.address + synchronized(connections) { + for (i in 0 until connections.size()) { + val conn = connections.valueAt(i) + if (conn.address == address) { + conn.close() + connections.removeAt(i) + break + } + } + } + } +} diff --git a/ios/flutter_classic_bluetooth/Sources/flutter_classic_bluetooth/FlutterClassicBluetoothPlugin.swift b/ios/flutter_classic_bluetooth/Sources/flutter_classic_bluetooth/FlutterClassicBluetoothPlugin.swift index c1b7fe2..6c51fdb 100644 --- a/ios/flutter_classic_bluetooth/Sources/flutter_classic_bluetooth/FlutterClassicBluetoothPlugin.swift +++ b/ios/flutter_classic_bluetooth/Sources/flutter_classic_bluetooth/FlutterClassicBluetoothPlugin.swift @@ -25,6 +25,28 @@ public class FlutterClassicBluetoothPlugin: NSObject, FlutterPlugin { adapterChannel.setStreamHandler(instance.adapterStateHandler) registrar.addMethodCallDelegate(instance, channel: channel) + + EAAccessoryManager.shared().registerForLocalNotifications() + NotificationCenter.default.addObserver( + instance, + selector: #selector(accessoryDidDisconnect(_:)), + name: .EAAccessoryDidDisconnect, + object: nil + ) + } + + @objc private func accessoryDidDisconnect(_ notification: Notification) { + guard let accessory = notification.userInfo?[EAAccessoryKey] as? EAAccessory else { + return + } + for (id, wrapper) in connections { + if wrapper.accessoryConnectionID == accessory.connectionID { + wrapper.close() + connections.removeValue(forKey: id) + + break + } + } } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -195,7 +217,7 @@ public class FlutterClassicBluetoothPlugin: NSObject, FlutterPlugin { let connId = nextConnectionId nextConnectionId += 1 - let wrapper = EASessionWrapper(id: connId, session: session, messenger: messenger) + let wrapper = EASessionWrapper(id: connId, session: session, accessoryConnectionID: accessory.connectionID, messenger: messenger) wrapper.startReading() connections[connId] = wrapper @@ -249,13 +271,15 @@ public class FlutterClassicBluetoothPlugin: NSObject, FlutterPlugin { class EASessionWrapper: NSObject, StreamDelegate { let id: Int + let accessoryConnectionID: Int private let session: EASession private var messenger: FlutterBinaryMessenger private var dataStreamHandler: IOSStreamHandler? private var stateStreamHandler: IOSStreamHandler? - init(id: Int, session: EASession, messenger: FlutterBinaryMessenger) { + init(id: Int, session: EASession, accessoryConnectionID: Int, messenger: FlutterBinaryMessenger) { self.id = id + self.accessoryConnectionID = accessoryConnectionID self.session = session self.messenger = messenger super.init()